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
+174 -11
View File
@@ -1,5 +1,11 @@
import { MoexClient } from './moex.client';
const emptyEvents = {
coupons: { columns: ['coupondate', 'value', 'faceunit'], data: [] },
offers: { columns: ['offerdate'], data: [] },
description: { columns: ['name', 'value'], data: [] },
};
describe('MoexClient', () => {
afterEach(() => jest.restoreAllMocks());
it('uses effective yield only for the exact ISIN and board and preserves its own price and date', async () => {
@@ -92,17 +98,174 @@ describe('MoexClient', () => {
expect(result?.value ?? null).toBe(value === null ? null : '0');
},
);
it('treats a successful response without the requested block as no events', async () => {
const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
description: { columns: ['name', 'value'], data: [] },
boards: { columns: ['secid'], data: [] },
}),
} as Response);
it('accepts confirmed empty blocks', async () => {
jest
.spyOn(global, 'fetch')
.mockImplementation(() => Promise.resolve(Response.json(emptyEvents)));
await expect(new MoexClient().bondEvents('BOND')).resolves.toEqual([]);
fetchMock.mockRestore();
});
it('preserves not-found semantics independently of malformed HTTP 200', async () => {
jest
.spyOn(global, 'fetch')
.mockImplementation(() =>
Promise.resolve(new Response(null, { status: 404 })),
);
const client = new MoexClient();
await expect(client.bondEvents('MISSING')).resolves.toEqual([]);
await expect(
client.bondYield('MISSING', 'ISIN', 'TQOB'),
).resolves.toBeNull();
});
it.each([
['missing block', { coupons: undefined }],
['missing columns', { coupons: { data: [] } }],
['invalid column', { coupons: { columns: [null], data: [] } }],
['invalid row', { coupons: { columns: ['coupondate'], data: [null] } }],
[
'short row',
{ coupons: { columns: ['coupondate', 'value'], data: [['2099-02-01']] } },
],
[
'long row',
{ coupons: { columns: ['coupondate'], data: [['2099-02-01', 2]] } },
],
[
'empty cursor',
{
'coupons.cursor': { columns: ['INDEX', 'TOTAL', 'PAGESIZE'], data: [] },
},
],
[
'missing cursor field',
{ 'coupons.cursor': { columns: ['INDEX', 'TOTAL'], data: [[0, 1]] } },
],
[
'null cursor index',
{
'coupons.cursor': {
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
data: [[null, 0, 1]],
},
},
],
[
'boolean cursor total',
{
'coupons.cursor': {
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
data: [[0, false, 1]],
},
},
],
[
'empty cursor total',
{
'coupons.cursor': {
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
data: [[0, '', 1]],
},
},
],
[
'short page',
{
'coupons.cursor': {
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
data: [[0, 1, 1]],
},
},
],
])(
'rejects %s without returning a partial calendar',
async (_name, invalid) => {
jest
.spyOn(global, 'fetch')
.mockImplementation(() =>
Promise.resolve(Response.json({ ...emptyEvents, ...invalid })),
);
await expect(new MoexClient().bondEvents('BOND')).rejects.toMatchObject({
response: { code: 'MOEX_UNAVAILABLE' },
});
},
);
it.each([0, -1])(
'rejects nonpositive page size %s before repeating a fetch',
async (pageSize) => {
const starts: string[] = [];
jest.spyOn(global, 'fetch').mockImplementation((input) => {
const url = new URL(input instanceof Request ? input.url : input);
if (url.searchParams.get('iss.only')?.startsWith('coupons,')) {
starts.push(url.searchParams.get('start') ?? '');
if (starts.length > 1)
return Promise.reject(new Error('Repeated page'));
}
return Promise.resolve(
Response.json({
...emptyEvents,
'coupons.cursor': {
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
data: [[0, 1, pageSize]],
},
}),
);
});
await expect(new MoexClient().bondEvents('BOND')).rejects.toMatchObject({
response: { code: 'MOEX_UNAVAILABLE' },
});
expect(starts).toEqual(['0']);
},
);
it.each([false, true])(
'reads all cursor pages and rejects a repeated index: %s',
async (repeatIndex) => {
const starts: string[] = [];
jest.spyOn(global, 'fetch').mockImplementation((input) => {
const url = new URL(input instanceof Request ? input.url : input);
const start = Number(url.searchParams.get('start'));
if (url.searchParams.get('iss.only')?.startsWith('coupons,')) {
starts.push(String(start));
if (starts.length > 2)
return Promise.reject(new Error('Repeated page'));
}
return Promise.resolve(
Response.json({
...emptyEvents,
coupons: {
columns: ['coupondate', 'value', 'faceunit'],
data: [[`2099-02-0${start + 1}`, '2.000000001', 'SUR']],
},
'coupons.cursor': {
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
data: [[repeatIndex ? 0 : start, 2, 1]],
},
}),
);
});
const result = new MoexClient().bondEvents('BOND');
if (repeatIndex)
await expect(result).rejects.toMatchObject({
response: { code: 'MOEX_UNAVAILABLE' },
});
else
await expect(result).resolves.toEqual([
{
type: 'coupon',
date: '2099-02-01',
value: '2.000000001',
currency: 'RUB',
},
{
type: 'coupon',
date: '2099-02-02',
value: '2.000000001',
currency: 'RUB',
},
]);
expect(starts).toEqual(['0', '1']);
},
);
});
+12 -9
View File
@@ -123,22 +123,26 @@ export class MoexClient {
rows.push(...current);
if (!paginated) return rows;
const cursorBlock = response[`${block}.cursor`];
if (!cursorBlock) {
if (cursorBlock === undefined) {
if (current.length < FALLBACK_PAGE_SIZE) return rows;
start += FALLBACK_PAGE_SIZE;
continue;
}
const cursor = parseRows(cursorBlock)[0];
const cursors = parseRows(cursorBlock);
const cursor = cursors[0];
const index = integer(cursor?.index);
const total = integer(cursor?.total);
const pageSize = integer(cursor?.pagesize);
if (
index === null ||
cursors.length !== 1 ||
index !== start ||
total === null ||
pageSize === null ||
index + pageSize >= total
pageSize === 0 ||
current.length !== Math.min(pageSize, total - start)
)
return rows;
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
if (index + pageSize >= total) return rows;
start = index + pageSize;
}
}
@@ -161,14 +165,13 @@ export class MoexClient {
}
function parseRows(block: IssBlock | undefined): Record<string, unknown>[] {
if (!block) return [];
if (!Array.isArray(block.columns) || !Array.isArray(block.data))
if (!Array.isArray(block?.columns) || !Array.isArray(block?.data))
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
const columns = block.columns;
if (!columns.every((column) => typeof column === 'string'))
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
return block.data.map((values) => {
if (!Array.isArray(values))
if (!Array.isArray(values) || values.length !== columns.length)
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
return Object.fromEntries(
columns.map((column, index) => [column.toLowerCase(), values[index]]),
@@ -191,6 +194,6 @@ function currencyText(value: unknown): string | null {
}
function integer(value: unknown): number | null {
const result = Number(value);
const result = Number(numberText(value) ?? NaN);
return Number.isSafeInteger(result) && result >= 0 ? result : null;
}
@@ -1,162 +0,0 @@
import {
BadRequestException,
ConflictException,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
jest.mock('../../prisma/prisma.service', () => ({
PrismaService: class PrismaService {},
}));
import type { TbankAccountContext } from './tbank-access.service';
import { TbankAccessService } from './tbank-access.service';
import { TbankTokenService } from './tbank-token.service';
describe('TbankAccessService', () => {
const prisma = { tbankConnection: { findUnique: jest.fn() } };
const token = {
decrypt: jest.fn(),
translateInvalidToken: jest.fn((operation: () => Promise<unknown>) =>
new TbankTokenService({
getOrThrow: () => 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=',
} as never).translateInvalidToken(operation),
),
};
const tbank = { getOpenAccounts: jest.fn() };
const service = new TbankAccessService(
prisma as never,
token as never,
tbank as never,
);
beforeEach(() => {
jest.clearAllMocks();
prisma.tbankConnection.findUnique.mockResolvedValue({
encryptedToken: 'encrypted',
});
token.decrypt.mockReturnValue('plain-token');
tbank.getOpenAccounts.mockResolvedValue([
{
id: 'account',
type: 'ACCOUNT_TYPE_TINKOFF',
name: ' Основной ',
openedDate: { seconds: '1723507200' },
},
{ id: 'debit', type: 'ACCOUNT_TYPE_DEBIT', name: 'Debit' },
]);
});
it('lists supported accounts through the saved decrypted token', async () => {
await expect(service.listSupportedAccounts('user')).resolves.toEqual([
{
id: 'account',
type: 'BROKERAGE',
name: 'Основной',
openedOn: '2024-08-13',
},
]);
expect(prisma.tbankConnection.findUnique).toHaveBeenCalledWith({
where: { userId: 'user' },
});
expect(token.decrypt).toHaveBeenCalledWith('encrypted');
expect(tbank.getOpenAccounts).toHaveBeenCalledWith('plain-token');
});
it('rejects missing connections before calling T-Bank', async () => {
prisma.tbankConnection.findUnique.mockResolvedValue(null);
await expect(service.listSupportedAccounts('user')).rejects.toEqual(
expect.any(ConflictException),
);
expect(tbank.getOpenAccounts).not.toHaveBeenCalled();
});
it('resolves the persisted connection generation without an upstream account call', async () => {
const context = await service.withConnection('user', (value) => value);
expect(context).toEqual({
token: 'plain-token',
generation: expect.any(String) as string,
});
expect(tbank.getOpenAccounts).not.toHaveBeenCalled();
});
it('translates invalid tokens and preserves unavailable upstream errors', async () => {
tbank.getOpenAccounts.mockRejectedValueOnce(
new Error('INVALID_TBANK_TOKEN'),
);
await expect(service.listSupportedAccounts('user')).rejects.toEqual(
expect.any(BadRequestException),
);
const unavailable = new ServiceUnavailableException({
code: 'TBANK_UNAVAILABLE',
});
tbank.getOpenAccounts.mockRejectedValueOnce(unavailable);
await expect(service.listSupportedAccounts('user')).rejects.toBe(
unavailable,
);
});
it('wraps account-scoped handlers with resolved account access', async () => {
const handler = jest.fn((context: TbankAccountContext) =>
Promise.resolve({
resolvedToken: context.token,
generation: context.generation,
accountId: context.account.id,
}),
);
await expect(
service.withAccount('user', 'account', handler),
).resolves.toEqual({
resolvedToken: 'plain-token',
generation: expect.any(String) as string,
accountId: 'account',
});
});
it('derives a stable opaque generation from each persisted encrypted payload', async () => {
const generation = () =>
service.withAccount('user', 'account', ({ generation: value }) =>
Promise.resolve(value),
);
prisma.tbankConnection.findUnique
.mockResolvedValueOnce({ encryptedToken: 'encrypted:first-nonce' })
.mockResolvedValueOnce({ encryptedToken: 'encrypted:first-nonce' })
.mockResolvedValueOnce({ encryptedToken: 'encrypted:second-nonce' });
const first = await generation();
const sameRecord = await generation();
const replaced = await generation();
expect(sameRecord).toBe(first);
expect(replaced).not.toBe(first);
expect(first).not.toContain('plain-token');
expect(first).not.toContain('encrypted:first-nonce');
});
it('rejects inaccessible accounts before running the handler', async () => {
const handler = jest.fn();
await expect(
service.withAccount('user', 'missing', handler),
).rejects.toEqual(expect.any(NotFoundException));
expect(handler).not.toHaveBeenCalled();
});
it('translates invalid-token handler failures and preserves unrelated handler failures', async () => {
await expect(
service.withAccount('user', 'account', () =>
Promise.reject(new Error('INVALID_TBANK_TOKEN')),
),
).rejects.toEqual(expect.any(BadRequestException));
const failure = new Error('source failed');
await expect(
service.withAccount('user', 'account', () => Promise.reject(failure)),
).rejects.toBe(failure);
});
});
+3 -94
View File
@@ -5,24 +5,11 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { createHash, randomUUID } from 'node:crypto';
import { randomUUID } from 'node:crypto';
import { PrismaService } from '../../prisma/prisma.service';
import {
SupportedTbankAccount,
accessibleTbankAccount,
supportedTbankAccounts,
} from './tbank-account.policy';
import { supportedTbankAccounts } from './tbank-account.policy';
import { TbankTokenService } from './tbank-token.service';
import { TbankAccount, TbankClient } from './tbank.client';
export type TbankConnectionContext = {
token: string;
generation: string;
};
export type TbankAccountContext = TbankConnectionContext & {
account: TbankAccount;
};
import type { TbankAccount } from './tbank.client';
export type TbankSyncAttempt = {
userId: string;
@@ -36,7 +23,6 @@ export class TbankAccessService {
constructor(
private readonly prisma: PrismaService,
private readonly tokens: TbankTokenService,
private readonly tbank: TbankClient,
) {}
async reserveMembership(
@@ -251,81 +237,4 @@ export class TbankAccessService {
throw new NotFoundException();
return connection;
}
async listSupportedAccounts(
userId: string,
): Promise<SupportedTbankAccount[]> {
return this.withSupportedAccounts(userId, ({ accounts }) => accounts);
}
async withSupportedAccounts<T>(
userId: string,
handler: (context: {
token: string;
generation: string;
accounts: SupportedTbankAccount[];
}) => Promise<T> | T,
): Promise<T> {
return this.withConnection(userId, async ({ token, generation }) =>
handler({
token,
generation,
accounts: supportedTbankAccounts(
await this.tbank.getOpenAccounts(token),
),
}),
);
}
async withConnection<T>(
userId: string,
handler: (context: TbankConnectionContext) => Promise<T> | T,
): Promise<T> {
const connection = await this.connection(userId);
return this.tokens.translateInvalidToken(async () => handler(connection));
}
async withAccount<T>(
userId: string,
accountId: string,
handler: (context: TbankAccountContext) => Promise<T>,
): Promise<T> {
return this.withConnection(userId, (connection) =>
this.withAccountForConnection(connection, accountId, handler),
);
}
async withAccountForConnection<T>(
connection: TbankConnectionContext,
accountId: string,
handler: (context: TbankAccountContext) => Promise<T>,
): Promise<T> {
const { token, generation } = connection;
return this.tokens.translateInvalidToken(async () => {
const account = accessibleTbankAccount(
await this.tbank.getOpenAccounts(token),
accountId,
);
if (!account) throw new NotFoundException();
return handler({ token, generation, account });
});
}
private async connection(
userId: string,
): Promise<{ token: string; generation: string }> {
const connection = await this.prisma.tbankConnection.findUnique({
where: { userId },
});
if (!connection)
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
if (connection.invalidToken)
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
return {
token: this.tokens.decrypt(connection.encryptedToken),
generation: createHash('sha256')
.update(connection.encryptedToken)
.digest('base64url'),
};
}
}
@@ -1,7 +1,4 @@
import {
accessibleTbankAccount,
supportedTbankAccounts,
} from './tbank-account.policy';
import { supportedTbankAccounts } from './tbank-account.policy';
describe('tbank-account-policy', () => {
it('normalizes supported account types and fallback names', () => {
@@ -45,26 +42,4 @@ describe('tbank-account-policy', () => {
},
]);
});
it('matches account-scoped access only for supported account types', () => {
expect(
accessibleTbankAccount(
[
{ id: 'account', type: 'ACCOUNT_TYPE_DEBIT', name: 'Debit' },
{ id: 'other', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Other' },
],
'account',
),
).toBeNull();
expect(
accessibleTbankAccount(
[{ id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Brokerage' }],
'account',
),
).toEqual({
id: 'account',
type: 'ACCOUNT_TYPE_TINKOFF',
name: 'Brokerage',
});
});
});
@@ -39,17 +39,6 @@ export function supportedTbankAccounts(
});
}
export function accessibleTbankAccount(
accounts: TbankAccount[],
accountId: string,
): TbankAccount | null {
return (
accounts.find(
(account) => account.id === accountId && account.type in accountTypes,
) ?? null
);
}
function openedOn(seconds: string | number | undefined): string | null {
if (seconds === undefined) return null;
const date = new Date(Number(seconds) * MILLISECONDS_PER_SECOND);
+2 -1
View File
@@ -2,13 +2,14 @@
export { InstrumentMetadataService } from './instrument-metadata.service';
export {
TbankAccessService,
type TbankConnectionContext,
type TbankSyncAttempt,
} from './tbank-access.service';
export {
type TbankBond,
type TbankCandle,
type TbankCoupon,
type TbankFundamentals,
type TbankLastPrice,
type TbankShare,
} from './tbank-market.types';
export { TbankTokenService } from './tbank-token.service';