refactor: enforce enum machine codes
Backend security / security (push) Successful in 8s
Backend security / security (pull_request) Canceled after 0s
Backend verify / verify (push) Canceled after 9s
Backend verify / verify (pull_request) Canceled after 4s
Conventional commits / commits (pull_request) Successful in 31s

Use owner enums for error and T-Bank protocol values with ESLint regression checks.

Preserve open wire fallbacks and keep protocol imports independent of DI and Prisma.

Verified npm ci, verify, 226 unit tests, 262 isolated e2e tests and unchanged OpenAPI.
This commit is contained in:
2026-09-28 13:18:06 +03:00
parent 0303e60b64
commit 2d14081d27
47 changed files with 432 additions and 164 deletions
+7 -5
View File
@@ -1,6 +1,8 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { constants as httpStatus } from 'node:http2';
enum MoexErrorCode { Unavailable = 'MOEX_UNAVAILABLE' }
const MIN_PRICE_PERCENT_EXCLUSIVE = 0;
const FIRST_PAGE_OFFSET = 0;
const EXPECTED_CURSOR_ROWS = 1;
@@ -152,7 +154,7 @@ export class MoexClient {
pageSize === EMPTY_PAGE_SIZE ||
current.length !== Math.min(pageSize, total - start)
) {
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
}
if (index + pageSize >= total) return rows;
start = index + pageSize;
@@ -172,22 +174,22 @@ export class MoexClient {
}
return body as IssResponse;
} catch {
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
}
}
}
function parseRows(block: IssBlock | undefined): Record<string, unknown>[] {
if (!Array.isArray(block?.columns) || !Array.isArray(block?.data)) {
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
}
const columns = block.columns;
if (!columns.every((column) => typeof column === 'string')) {
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
}
return block.data.map((values) => {
if (!Array.isArray(values) || values.length !== columns.length) {
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
}
return Object.fromEntries(
// eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names; row indexes come from the validated columns array.
+3 -1
View File
@@ -4,6 +4,8 @@ import { LRUCache } from 'lru-cache';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time';
enum NcrErrorCode { Unavailable = 'NCR_UNAVAILABLE' }
const EXPECTED_REGISTRY_TABLES = 1;
const RATING_COLUMN = 2;
@@ -41,7 +43,7 @@ export class NcrRatingsClient {
if (this.cache.get('registry') === pending) {
this.cache.set('registry', pending, { ttl: 60_000 });
}
throw new ServiceUnavailableException({ code: 'NCR_UNAVAILABLE' });
throw new ServiceUnavailableException({ code: NcrErrorCode.Unavailable });
});
this.cache.set('registry', pending);
}
@@ -11,6 +11,7 @@ import type { TbankSyncAttempt } from './tbank-access.service';
import type { TbankInstrument } from './tbank.client';
import { PrismaService } from '../../prisma/prisma.service';
import { TbankErrorCode } from './tbank-error-code';
import { tbankSourceTtlMs } from './tbank-source.policy';
const FIRST_BATCH_OFFSET = 0;
@@ -276,7 +277,7 @@ export class InstrumentMetadataRepository {
},
data: {
state: success ? 'complete' : 'failed',
errorCode: success ? null : 'TBANK_UNAVAILABLE',
errorCode: success ? null : TbankErrorCode.Unavailable,
},
});
if (success) {
@@ -286,7 +287,7 @@ export class InstrumentMetadataRepository {
state: 'running',
connection: { invalidToken: true },
},
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' },
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
});
}
}
@@ -12,6 +12,7 @@ import {
TbankAccessService,
type TbankSyncAttempt,
} from './tbank-access.service';
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
import { TbankClient, type TbankInstrument } from './tbank.client';
export { metadataKey } from './instrument-metadata.repository';
export type { MetadataIdentity } from './instrument-metadata.repository';
@@ -131,7 +132,7 @@ export class InstrumentMetadataService {
if (
!identity.figi ||
(error instanceof Error &&
error.message === 'INVALID_TBANK_TOKEN')
isTbankErrorCode(error.message, TbankErrorCode.InvalidToken))
) {
throw error;
}
@@ -191,11 +192,11 @@ export class InstrumentMetadataService {
);
}
} catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
await this.access.rejectMembershipToken(attempt);
} else {
this.logger.event('warn', 'instrument-metadata.failed', {
code: 'TBANK_UNAVAILABLE',
code: TbankErrorCode.Unavailable,
});
}
}
@@ -209,7 +210,7 @@ export class InstrumentMetadataService {
}
} catch {
this.logger.event('error', 'instrument-metadata.persistence-failed', {
code: 'TBANK_UNAVAILABLE',
code: TbankErrorCode.Unavailable,
});
this.active.clear();
this.producers.clear();
@@ -10,6 +10,7 @@ import type { TbankAccount } from './tbank.client';
import { PrismaService } from '../../prisma/prisma.service';
import { supportedTbankAccounts } from './tbank-account.policy';
import { TbankErrorCode } from './tbank-error-code';
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
import { TbankTokenService } from './tbank-token.service';
@@ -103,7 +104,7 @@ export class TbankAccessService {
state: 'running',
connection: { invalidToken: false },
},
data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' },
data: { state: 'failed', errorCode: TbankErrorCode.Unavailable },
});
return result.count === EXPECTED_UPDATED_CONNECTIONS;
}
@@ -205,7 +206,7 @@ export class TbankAccessService {
return this.prisma.$transaction(async (tx) => {
const claimed = await tx.tbankSyncWork.updateMany({
where: { ...attempt, state: 'running' },
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' },
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
});
if (!claimed.count) return false;
await tx.tbankConnection.update({
@@ -236,10 +237,10 @@ export class TbankAccessService {
},
});
if (!connection) {
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
throw new ConflictException({ code: TbankErrorCode.NotConnected });
}
if (connection.invalidToken) {
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
}
if (
connection.membershipAt &&
@@ -1,14 +1,15 @@
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../common/time';
import { TbankAccountType } from './tbank-protocol';
import { TbankAccount } from './tbank.client';
const accountTypes = {
ACCOUNT_TYPE_TINKOFF: { type: 'BROKERAGE', name: 'Брокерский счет' },
ACCOUNT_TYPE_TINKOFF_IIS: {
[TbankAccountType.Tinkoff]: { type: 'BROKERAGE', name: 'Брокерский счет' },
[TbankAccountType.TinkoffIis]: {
type: 'INDIVIDUAL_INVESTMENT',
name: 'ИИС',
},
ACCOUNT_TYPE_INVEST_BOX: { type: 'INVEST_BOX', name: 'Инвесткопилка' },
ACCOUNT_TYPE_INVEST_FUND: {
[TbankAccountType.InvestBox]: { type: 'INVEST_BOX', name: 'Инвесткопилка' },
[TbankAccountType.InvestFund]: {
type: 'MONEY_MARKET_FUND',
name: 'Фонд денежного рынка',
},
@@ -0,0 +1,10 @@
export enum TbankErrorCode {
InvalidToken = 'INVALID_TBANK_TOKEN',
NotConnected = 'TBANK_NOT_CONNECTED',
Unavailable = 'TBANK_UNAVAILABLE',
}
// Error messages and persisted codes cross an open string boundary.
export function isTbankErrorCode(value: unknown, expected: TbankErrorCode): boolean {
return value === expected;
}
@@ -6,6 +6,7 @@ import {
TbankAccessService,
type TbankSyncAttempt,
} from './tbank-access.service';
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
import { TbankClient } from './tbank.client';
@Injectable()
@@ -37,7 +38,7 @@ export class TbankMembershipService {
.catch(() =>
this.logger.event('error', 'tbank.membership.persistence_failed', {
userId,
code: 'TBANK_UNAVAILABLE',
code: TbankErrorCode.Unavailable,
}),
)
.finally(() => this.active.delete(attemptId));
@@ -62,7 +63,7 @@ export class TbankMembershipService {
} catch (error) {
await this.access.failMembership(
attempt,
error instanceof Error && error.message === 'INVALID_TBANK_TOKEN',
error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken),
);
}
}
@@ -0,0 +1,42 @@
import { loadSync } from '@grpc/proto-loader';
import { join } from 'node:path';
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
import {
InstrumentIdType,
isTbankProtocolValue,
TbankAccountStatus,
TbankAccountType,
TbankCandleInterval,
TbankCouponType,
TbankInstrumentType,
TbankOperationState,
TbankOperationType,
} from './tbank-protocol';
it('uses the string enum values declared by the vendored protocol', () => {
const directory = join(__dirname, 'proto/contracts');
const definitions = loadSync(['users.proto', 'instruments.proto', 'operations.proto', 'marketdata.proto'].map((file) => join(directory, file)), { includeDirs: [directory], enums: String });
for (const [name, values] of Object.entries({
InstrumentIdType,
InstrumentType: TbankInstrumentType,
AccountStatus: TbankAccountStatus,
AccountType: TbankAccountType,
CandleInterval: TbankCandleInterval,
CouponType: TbankCouponType,
OperationState: TbankOperationState,
OperationType: TbankOperationType,
})) {
const definition = definitions[`tinkoff.public.invest.api.contract.v1.${name}`];
expect(definition).toHaveProperty('format', 'Protocol Buffer 3 EnumDescriptorProto');
const descriptor = definition as { type: { value: { name: string }[] } };
expect(descriptor.type.value.map((member) => member.name)).toEqual(expect.arrayContaining(Object.values(values)));
}
});
it('recognizes supported wire values without accepting unknown ones', () => {
expect(isTbankProtocolValue('OPERATION_STATE_EXECUTED', TbankOperationState.Executed)).toBe(true);
expect(isTbankProtocolValue('NEW_UPSTREAM_STATE', TbankOperationState.Executed)).toBe(false);
expect(isTbankErrorCode('INVALID_TBANK_TOKEN', TbankErrorCode.InvalidToken)).toBe(true);
expect(isTbankErrorCode('NEW_UPSTREAM_ERROR', TbankErrorCode.InvalidToken)).toBe(false);
});
+70
View File
@@ -0,0 +1,70 @@
// String wire values for the protocol subset used by this integration.
// proto-loader decodes enums as strings; vendor contracts remain the source.
export enum InstrumentIdType {
Figi = 'INSTRUMENT_ID_TYPE_FIGI',
Uid = 'INSTRUMENT_ID_TYPE_UID',
}
export enum TbankInstrumentType {
Bond = 'INSTRUMENT_TYPE_BOND',
Share = 'INSTRUMENT_TYPE_SHARE',
}
export enum TbankAccountStatus {
Open = 'ACCOUNT_STATUS_OPEN',
}
export enum TbankAccountType {
InvestBox = 'ACCOUNT_TYPE_INVEST_BOX',
InvestFund = 'ACCOUNT_TYPE_INVEST_FUND',
Tinkoff = 'ACCOUNT_TYPE_TINKOFF',
TinkoffIis = 'ACCOUNT_TYPE_TINKOFF_IIS',
}
export enum TbankCandleInterval {
Day = 'CANDLE_INTERVAL_DAY',
}
export enum TbankOperationState {
Executed = 'OPERATION_STATE_EXECUTED',
}
export enum TbankCouponType {
Floating = 'COUPON_TYPE_FLOATING',
Unspecified = 'COUPON_TYPE_UNSPECIFIED',
}
export enum TbankOperationType {
BondTax = 'OPERATION_TYPE_BOND_TAX',
BrokerFee = 'OPERATION_TYPE_BROKER_FEE',
Buy = 'OPERATION_TYPE_BUY',
BuyCard = 'OPERATION_TYPE_BUY_CARD',
BuyMargin = 'OPERATION_TYPE_BUY_MARGIN',
Coupon = 'OPERATION_TYPE_COUPON',
DivExt = 'OPERATION_TYPE_DIV_EXT',
Dividend = 'OPERATION_TYPE_DIVIDEND',
DividendTax = 'OPERATION_TYPE_DIVIDEND_TAX',
DividendTransfer = 'OPERATION_TYPE_DIVIDEND_TRANSFER',
Input = 'OPERATION_TYPE_INPUT',
InputSecurities = 'OPERATION_TYPE_INPUT_SECURITIES',
MarginFee = 'OPERATION_TYPE_MARGIN_FEE',
Output = 'OPERATION_TYPE_OUTPUT',
OutputSecurities = 'OPERATION_TYPE_OUTPUT_SECURITIES',
Sell = 'OPERATION_TYPE_SELL',
SellCard = 'OPERATION_TYPE_SELL_CARD',
SellMargin = 'OPERATION_TYPE_SELL_MARGIN',
ServiceFee = 'OPERATION_TYPE_SERVICE_FEE',
SuccessFee = 'OPERATION_TYPE_SUCCESS_FEE',
Tax = 'OPERATION_TYPE_TAX',
TransBsBs = 'OPERATION_TYPE_TRANS_BS_BS',
TransIisBs = 'OPERATION_TYPE_TRANS_IIS_BS',
Unspecified = 'OPERATION_TYPE_UNSPECIFIED',
}
// Provider responses may contain values newer than our supported protocol subset.
export function isTbankProtocolValue(
value: unknown,
expected: TbankCouponType | TbankInstrumentType | TbankOperationState | TbankOperationType,
): boolean {
return value === expected;
}
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Environment } from '../../config/env.validation';
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
import { TbankCrypto } from './tbank.crypto';
@Injectable()
@@ -26,8 +27,8 @@ export class TbankTokenService {
try {
return await operation();
} catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
}
throw error;
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { status } from '@grpc/grpc-js';
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import { TbankOperationState } from './tbank-protocol';
import { TbankClient } from './tbank.client';
describe('TbankClient operations', () => {
@@ -10,7 +11,7 @@ describe('TbankClient operations', () => {
to: { seconds: '2' },
cursor: 'cursor',
limit: 10,
state: 'OPERATION_STATE_EXECUTED' as const,
state: TbankOperationState.Executed,
};
it('passes a cursor operations request to OperationsService', async () => {
+23 -15
View File
@@ -29,6 +29,14 @@ import type {
import { StructuredLogger } from '../../common/logging/structured-logger.service';
import { MILLISECONDS_PER_SECOND } from '../../common/time';
import { Environment } from '../../config/env.validation';
import { TbankErrorCode } from './tbank-error-code';
import {
InstrumentIdType,
TbankAccountStatus,
TbankCandleInterval,
TbankInstrumentType,
TbankOperationState,
} from './tbank-protocol';
const INITIAL_REQUEST_COUNT = 0;
const MIN_RATE_DELAY_MS = 1;
@@ -115,7 +123,7 @@ export type TbankOperationsByCursorRequest = {
from?: TbankTimestamp;
instrumentId?: string;
limit?: number;
state: 'OPERATION_STATE_EXECUTED';
state: TbankOperationState.Executed;
to?: TbankTimestamp;
};
export type TbankOperationsByCursorResponse = {
@@ -256,7 +264,7 @@ export class TbankClient {
token,
this.users,
'getAccounts',
{ status: 'ACCOUNT_STATUS_OPEN' },
{ status: TbankAccountStatus.Open },
).then((response) => response.accounts ?? []);
}
@@ -279,14 +287,14 @@ export class TbankClient {
token: string,
figi: string,
): Promise<null | TbankInstrument> {
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_FIGI', figi);
return this.getInstrument(token, InstrumentIdType.Figi, figi);
}
getInstrumentByUid(
token: string,
instrumentUid: string,
): Promise<null | TbankInstrument> {
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_UID', instrumentUid);
return this.getInstrument(token, InstrumentIdType.Uid, instrumentUid);
}
getDividends(
@@ -314,7 +322,7 @@ export class TbankClient {
'findInstrument',
{
query,
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
instrumentKind: TbankInstrumentType.Share,
},
).then((response) => response.instruments ?? []);
}
@@ -326,7 +334,7 @@ export class TbankClient {
'findInstrument',
{
query,
instrumentKind: 'INSTRUMENT_TYPE_BOND',
instrumentKind: TbankInstrumentType.Bond,
},
).then((response) => response.instruments ?? []);
}
@@ -337,7 +345,7 @@ export class TbankClient {
this.sparseInstruments,
'bondBy',
{
idType: 'INSTRUMENT_ID_TYPE_UID',
idType: InstrumentIdType.Uid,
id: uid,
},
).then((response) => response.instrument ?? null);
@@ -367,7 +375,7 @@ export class TbankClient {
this.instruments,
'shareBy',
{
idType: 'INSTRUMENT_ID_TYPE_UID',
idType: InstrumentIdType.Uid,
id: uid,
},
).then((response) => response.instrument ?? null);
@@ -446,7 +454,7 @@ export class TbankClient {
to: {
seconds: String(Math.floor(to.valueOf() / MILLISECONDS_PER_SECOND)),
},
interval: 'CANDLE_INTERVAL_DAY',
interval: TbankCandleInterval.Day,
limit: 2400,
},
).then((response) => response.candles ?? []);
@@ -454,7 +462,7 @@ export class TbankClient {
private async getInstrument(
token: string,
idType: 'INSTRUMENT_ID_TYPE_FIGI' | 'INSTRUMENT_ID_TYPE_UID',
idType: InstrumentIdType,
id: string,
): Promise<null | TbankInstrument> {
try {
@@ -484,7 +492,7 @@ export class TbankClient {
const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
try {
if (queueFull) {
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
}
const response = await this.queue.run(() =>
this.request<Response>(token, client, method, request),
@@ -498,7 +506,7 @@ export class TbankClient {
} catch (error) {
const grpcCode = (error as ServiceError).code;
const failureContext = queueFull
? { code: 'TBANK_UNAVAILABLE' }
? { code: TbankErrorCode.Unavailable }
: { grpcCode };
const retryable = queueFull
? metadataCall
@@ -534,7 +542,7 @@ export class TbankClient {
[status.PERMISSION_DENIED, status.UNAUTHENTICATED].includes(grpcCode)
) {
// eslint-disable-next-line preserve-caught-error -- Discard raw upstream authentication errors at the integration boundary.
throw new Error('INVALID_TBANK_TOKEN');
throw new Error(TbankErrorCode.InvalidToken);
}
if (
grpcCode === status.NOT_FOUND &&
@@ -542,10 +550,10 @@ export class TbankClient {
) {
throw new NotFoundException();
}
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
}
}
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
}
private request<Response>(