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
+93
View File
@@ -11,6 +11,8 @@ import { PrismaService } from '../src/prisma/prisma.service';
import { InstrumentMetadataRepository } from '../src/integrations/tbank/instrument-metadata.repository';
import { TbankClient } from '../src/integrations/tbank/tbank.client';
import { BrokerageOperationsResourceDto } from '../src/modules/investments/operations/presentation/brokerage-operation.dto';
import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository';
import type { IncomeAnalyticsResourceDto } from '../src/modules/investments/income/income-analytics.dto';
const uid = '11111111-1111-4111-8111-111111111111';
const period = 'from=2025-01-01&to=2025-01-31';
@@ -127,6 +129,97 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
});
it('uses the persisted subsecond timestamp for filtering, cursor order and income entries', async () => {
await prisma.tbankOperation.deleteMany();
const { revision } = await prisma.tbankConnection.findUniqueOrThrow({
where: { userId: 'owner' },
});
const repository = app.get(BrokerageOperationsRepository);
const interval = {
accountId: 'a',
from: new Date('2025-01-02T00:00:00.500Z'),
to: new Date('2025-01-02T00:00:01.000Z'),
};
const reserved = await repository.request(
'owner',
revision,
[interval],
'subsecond-test',
true,
new Set(),
1,
);
assert(reserved);
const attempt = {
userId: 'owner',
revision,
id: reserved.work.id,
attemptId: reserved.work.attemptId,
};
await expect(
repository.stagePage(attempt, interval, undefined, {
hasNext: false,
items: [{ id: 'invalid', date: {} }],
}),
).rejects.toThrow('Invalid operation timestamp');
await repository.stagePage(attempt, interval, undefined, {
hasNext: false,
items: [400, 600, 800].map((milliseconds) => ({
id: String(milliseconds),
date: { seconds: '1735776000', nanos: milliseconds * 1_000_000 },
type: 'OPERATION_TYPE_COUPON',
payment: { currency: 'rub', units: '1', nano: 1 },
})),
});
await repository.publish(attempt, [interval], new Date(), true);
const saved = await prisma.tbankOperation.findMany({
orderBy: { occurredAt: 'desc' },
});
const expected = [
{ id: '800', occurredAt: '2025-01-02T00:00:00.800Z' },
{ id: '600', occurredAt: '2025-01-02T00:00:00.600Z' },
];
expect(
saved.map((row) => ({
id: row.brokerOperationId,
occurredAt: row.occurredAt.toISOString(),
})),
).toEqual(expected);
const items: { id: string; occurredAt: string }[] = [];
let cursor: string | null = null;
do {
const response = await request(app.getHttpServer())
.get(
`/api/v1/brokerage-accounts/a/operations?from=${interval.from.toISOString()}&to=2025-01-02T00:00:00.900Z&limit=1&sync=observe${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`,
)
.set('Authorization', auth())
.expect(200);
const result = response.body as BrokerageOperationsResourceDto;
assert(result.data);
expect(result.data.items).toHaveLength(1);
items.push(
...result.data.items.map(({ id, occurredAt }) => ({ id, occurredAt })),
);
cursor = result.data.nextCursor;
} while (cursor);
const income = (
await request(app.getHttpServer())
.get(
`/api/v1/brokerage-accounts/a/income-analytics?${period}&sync=observe`,
)
.set('Authorization', auth())
.expect(200)
).body as IncomeAnalyticsResourceDto;
expect(items).toEqual(expected);
expect(
income.data?.entries.map(({ id, occurredAt }) => ({ id, occurredAt })),
).toEqual(expected);
expect(income.data?.totalsByCurrency[0].total).toEqual({
currency: 'RUB',
value: '2.000000002',
});
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
});
it.each(['default', 'today', 'today timestamp'])(
'keeps observed current coverage complete through %s period pagination without RPC',
async (periodKind) => {