refactor: replace ignored numeric literals with constants
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 2m17s
Conventional commits / commits (pull_request) Successful in 31s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 1m56s
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 2m17s
Conventional commits / commits (pull_request) Successful in 31s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 1m56s
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { constants as httpStatus } from 'node:http2';
|
||||
|
||||
const MIN_PRICE_PERCENT_EXCLUSIVE = 0;
|
||||
const FIRST_PAGE_OFFSET = 0;
|
||||
const EXPECTED_CURSOR_ROWS = 1;
|
||||
const EMPTY_PAGE_SIZE = 0;
|
||||
const MIN_CURSOR_VALUE = 0;
|
||||
|
||||
const FALLBACK_PAGE_SIZE = 100;
|
||||
|
||||
const baseUrl = 'https://iss.moex.com/iss';
|
||||
@@ -52,7 +58,7 @@ export class MoexClient {
|
||||
if (
|
||||
value === null ||
|
||||
pricePercent === null ||
|
||||
Number(pricePercent) <= 0 ||
|
||||
Number(pricePercent) <= MIN_PRICE_PERCENT_EXCLUSIVE ||
|
||||
!date ||
|
||||
!/^\d{4}-\d{2}-\d{2}$/.test(date) ||
|
||||
!Number.isFinite(Date.parse(date)) ||
|
||||
@@ -114,7 +120,7 @@ export class MoexClient {
|
||||
paginated = true,
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const rows: Record<string, unknown>[] = [];
|
||||
let start = 0;
|
||||
let start = FIRST_PAGE_OFFSET;
|
||||
for (;;) {
|
||||
const query = new URLSearchParams({
|
||||
'iss.meta': 'off',
|
||||
@@ -139,11 +145,11 @@ export class MoexClient {
|
||||
const total = integer(cursor?.total);
|
||||
const pageSize = integer(cursor?.pagesize);
|
||||
if (
|
||||
cursors.length !== 1 ||
|
||||
cursors.length !== EXPECTED_CURSOR_ROWS ||
|
||||
index !== start ||
|
||||
total === null ||
|
||||
pageSize === null ||
|
||||
pageSize === 0 ||
|
||||
pageSize === EMPTY_PAGE_SIZE ||
|
||||
current.length !== Math.min(pageSize, total - start)
|
||||
) {
|
||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
||||
@@ -207,5 +213,5 @@ function currencyText(value: unknown): null | string {
|
||||
|
||||
function integer(value: unknown): null | number {
|
||||
const result = Number(numberText(value) ?? NaN);
|
||||
return Number.isSafeInteger(result) && result >= 0 ? result : null;
|
||||
return Number.isSafeInteger(result) && result >= MIN_CURSOR_VALUE ? result : null;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { load } from 'cheerio';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time';
|
||||
|
||||
const EXPECTED_REGISTRY_TABLES = 1;
|
||||
const RATING_COLUMN = 2;
|
||||
|
||||
const REGISTRY_CACHE_TTL_MS = 21_600_000;
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
const COLUMN_COUNT = 6;
|
||||
@@ -55,7 +60,7 @@ export class NcrRatingsClient {
|
||||
.map((_, cell) => $(cell).text().trim())
|
||||
.get();
|
||||
if (
|
||||
table.length !== 1 ||
|
||||
table.length !== EXPECTED_REGISTRY_TABLES ||
|
||||
headings.length !== COLUMN_COUNT ||
|
||||
headings[2] !== 'Рейтинг' ||
|
||||
// eslint-disable-next-line security/detect-object-injection -- Column indexes are local numeric constants.
|
||||
@@ -73,7 +78,7 @@ export class NcrRatingsClient {
|
||||
const isin = cells.eq(ISIN_COLUMN).text().trim().toUpperCase();
|
||||
// Unassigned issues cannot safely be matched to a held instrument.
|
||||
if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(isin)) return;
|
||||
const raw = cells.eq(2).text().replace(/\s+/g, ' ').trim();
|
||||
const raw = cells.eq(RATING_COLUMN).text().replace(/\s+/g, ' ').trim();
|
||||
const withdrawn = raw.toLowerCase() === 'рейтинг отозван';
|
||||
if (
|
||||
!withdrawn &&
|
||||
@@ -93,7 +98,7 @@ export class NcrRatingsClient {
|
||||
const date = `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}`;
|
||||
if (
|
||||
!Number.isFinite(Date.parse(date)) ||
|
||||
new Date(date).toISOString().slice(0, 10) !== date
|
||||
new Date(date).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) !== date
|
||||
) {
|
||||
throw new Error('NCR date invalid');
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Decimal } from '../../common/decimal';
|
||||
|
||||
const MISSING_PROTO_COMPONENT = 0;
|
||||
|
||||
const NANO = 1_000_000_000n;
|
||||
|
||||
export type ProtoDecimal = { nano?: number | string; units?: number | string };
|
||||
|
||||
export function decimal(value: ProtoDecimal): Decimal {
|
||||
return {
|
||||
value: BigInt(value.units ?? 0) * NANO + BigInt(value.nano ?? 0),
|
||||
value: BigInt(value.units ?? MISSING_PROTO_COMPONENT) * NANO + BigInt(value.nano ?? MISSING_PROTO_COMPONENT),
|
||||
scale: 9,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ import type { TbankInstrument } from './tbank.client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { tbankSourceTtlMs } from './tbank-source.policy';
|
||||
|
||||
const FIRST_BATCH_OFFSET = 0;
|
||||
const UNCHANGED_REVISION_INCREMENT = 0;
|
||||
const REVISION_INCREMENT = 1;
|
||||
const MISSING_DATA_REVISION = 0;
|
||||
|
||||
export type MetadataIdentity = {
|
||||
figi?: null | string;
|
||||
instrumentUid?: null | string;
|
||||
@@ -59,7 +64,7 @@ export class InstrumentMetadataRepository {
|
||||
),
|
||||
];
|
||||
for (
|
||||
let offset = 0;
|
||||
let offset = FIRST_BATCH_OFFSET;
|
||||
offset < values.length;
|
||||
offset += metadataReadBatchSize
|
||||
) {
|
||||
@@ -81,7 +86,7 @@ export class InstrumentMetadataRepository {
|
||||
];
|
||||
const instruments: MetadataRow[] = [];
|
||||
for (
|
||||
let offset = 0;
|
||||
let offset = FIRST_BATCH_OFFSET;
|
||||
offset < uids.length;
|
||||
offset += metadataReadBatchSize
|
||||
) {
|
||||
@@ -229,8 +234,8 @@ export class InstrumentMetadataRepository {
|
||||
...data,
|
||||
dataRevision: {
|
||||
increment: isDeepStrictEqual(previous?.metadata, payload) ?
|
||||
0 :
|
||||
1,
|
||||
UNCHANGED_REVISION_INCREMENT :
|
||||
REVISION_INCREMENT,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -252,8 +257,8 @@ export class InstrumentMetadataRepository {
|
||||
fetchedAt: now,
|
||||
dataRevision: {
|
||||
increment: isDeepStrictEqual(previous?.matchedUids, matchedUids) ?
|
||||
0 :
|
||||
1,
|
||||
UNCHANGED_REVISION_INCREMENT :
|
||||
REVISION_INCREMENT,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -323,7 +328,7 @@ function selectMetadata(
|
||||
dataRevision: used
|
||||
.map(
|
||||
(row) =>
|
||||
`${row.lookup?.dataRevision ?? 0}:${row.instrument?.dataRevision ?? 0}`,
|
||||
`${row.lookup?.dataRevision ?? MISSING_DATA_REVISION}:${row.instrument?.dataRevision ?? MISSING_DATA_REVISION}`,
|
||||
)
|
||||
.join('/'),
|
||||
};
|
||||
|
||||
@@ -13,6 +13,10 @@ import { supportedTbankAccounts } from './tbank-account.policy';
|
||||
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
|
||||
import { TbankTokenService } from './tbank-token.service';
|
||||
|
||||
const EXPECTED_UPDATED_CONNECTIONS = 1;
|
||||
const MEMBERSHIP_REVISION_INCREMENT = 1;
|
||||
const UNCHANGED_MEMBERSHIP_REVISION_INCREMENT = 0;
|
||||
|
||||
export type TbankSyncAttempt = {
|
||||
attemptId: string;
|
||||
id: string;
|
||||
@@ -101,7 +105,7 @@ export class TbankAccessService {
|
||||
},
|
||||
data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' },
|
||||
});
|
||||
return result.count === 1;
|
||||
return result.count === EXPECTED_UPDATED_CONNECTIONS;
|
||||
}
|
||||
|
||||
async tokenForAttempt(attempt: TbankSyncAttempt): Promise<null | string> {
|
||||
@@ -184,7 +188,7 @@ export class TbankAccessService {
|
||||
where: { userId_revision: { userId, revision } },
|
||||
data: {
|
||||
membershipAt: fetchedAt,
|
||||
membershipRevision: { increment: changed ? 1 : 0 },
|
||||
membershipRevision: { increment: changed ? MEMBERSHIP_REVISION_INCREMENT : UNCHANGED_MEMBERSHIP_REVISION_INCREMENT },
|
||||
},
|
||||
});
|
||||
if (completeWork) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MILLISECONDS_PER_SECOND } from '../../common/time';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../common/time';
|
||||
import { TbankAccount } from './tbank.client';
|
||||
|
||||
const accountTypes = {
|
||||
@@ -43,5 +43,5 @@ function openedOn(seconds: number | string | undefined): null | string {
|
||||
if (seconds === undefined) return null;
|
||||
const date = new Date(Number(seconds) * MILLISECONDS_PER_SECOND);
|
||||
if (Number.isNaN(date.valueOf())) return null;
|
||||
return date.toISOString().slice(0, 10);
|
||||
return date.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,13 @@ import { StructuredLogger } from '../../common/logging/structured-logger.service
|
||||
import { MILLISECONDS_PER_SECOND } from '../../common/time';
|
||||
import { Environment } from '../../config/env.validation';
|
||||
|
||||
const INITIAL_REQUEST_COUNT = 0;
|
||||
const MIN_RATE_DELAY_MS = 1;
|
||||
const FIRST_ATTEMPT = 1;
|
||||
const ATTEMPT_INCREMENT = 1;
|
||||
const NO_RPC_ATTEMPTS = 0;
|
||||
const NEXT_DAY_OFFSET = 1;
|
||||
|
||||
const RATE_WINDOW_MS = 1_000;
|
||||
const METADATA_MAX_ATTEMPTS = 3;
|
||||
const RPC_MAX_ATTEMPTS = 2;
|
||||
@@ -124,7 +131,7 @@ type TbankNamespace = {
|
||||
|
||||
class RateLimiter {
|
||||
private readonly waiting: (() => void)[] = [];
|
||||
private starts = 0;
|
||||
private starts = INITIAL_REQUEST_COUNT;
|
||||
private windowStartedAt = Date.now();
|
||||
|
||||
constructor(private readonly perSecond: number) {}
|
||||
@@ -149,16 +156,16 @@ class RateLimiter {
|
||||
const now = Date.now();
|
||||
if (now - this.windowStartedAt >= RATE_WINDOW_MS) {
|
||||
this.windowStartedAt = now;
|
||||
this.starts = 0;
|
||||
this.starts = INITIAL_REQUEST_COUNT;
|
||||
}
|
||||
while (this.starts < this.perSecond && this.waiting.length) {
|
||||
this.starts += 1;
|
||||
this.starts++;
|
||||
this.waiting.shift()?.();
|
||||
}
|
||||
if (this.waiting.length && this.starts === this.perSecond) {
|
||||
setTimeout(
|
||||
() => this.drain(),
|
||||
Math.max(1, RATE_WINDOW_MS - (now - this.windowStartedAt)),
|
||||
Math.max(MIN_RATE_DELAY_MS, RATE_WINDOW_MS - (now - this.windowStartedAt)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -472,7 +479,7 @@ export class TbankClient {
|
||||
): Promise<Response> {
|
||||
const metadataCall = method === 'getInstrumentBy';
|
||||
const maxAttempts = metadataCall ? METADATA_MAX_ATTEMPTS : RPC_MAX_ATTEMPTS;
|
||||
for (let attempts = 1; attempts <= maxAttempts; attempts += 1) {
|
||||
for (let attempts = FIRST_ATTEMPT; attempts <= maxAttempts; attempts += ATTEMPT_INCREMENT) {
|
||||
const startedAt = performance.now();
|
||||
const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
|
||||
try {
|
||||
@@ -520,7 +527,7 @@ export class TbankClient {
|
||||
this.logger.event('error', 'tbank.rpc.failed', {
|
||||
operation: method,
|
||||
...failureContext,
|
||||
attempts: queueFull && !metadataCall ? 0 : attempts,
|
||||
attempts: queueFull && !metadataCall ? NO_RPC_ATTEMPTS : attempts,
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
});
|
||||
if (
|
||||
@@ -564,6 +571,6 @@ export class TbankClient {
|
||||
// Calendar owners supply inclusive UTC days; RPC timestamps use [from, next(to)).
|
||||
function utcSeconds(date: string, nextDay = false): number {
|
||||
const instant = new Date(`${date}T00:00:00Z`);
|
||||
if (nextDay) instant.setUTCDate(instant.getUTCDate() + 1);
|
||||
if (nextDay) instant.setUTCDate(instant.getUTCDate() + NEXT_DAY_OFFSET);
|
||||
return Math.floor(instant.valueOf() / MILLISECONDS_PER_SECOND);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||
|
||||
const VERSION_SIZE_BYTES = 1;
|
||||
|
||||
const KEY_SIZE_BYTES = 32;
|
||||
|
||||
const VERSION = 1;
|
||||
@@ -34,15 +36,15 @@ export class TbankCrypto {
|
||||
|
||||
decrypt(payload: string): string {
|
||||
const bytes = Buffer.from(payload, 'base64');
|
||||
if (bytes[0] !== VERSION || bytes.length < 1 + NONCE_SIZE + TAG_SIZE) {
|
||||
if (bytes[0] !== VERSION || bytes.length < VERSION_SIZE_BYTES + NONCE_SIZE + TAG_SIZE) {
|
||||
throw new Error('Invalid encrypted token');
|
||||
}
|
||||
const nonce = bytes.subarray(1, 1 + NONCE_SIZE);
|
||||
const nonce = bytes.subarray(VERSION_SIZE_BYTES, VERSION_SIZE_BYTES + NONCE_SIZE);
|
||||
const tag = bytes.subarray(-TAG_SIZE);
|
||||
const decipher = createDecipheriv('aes-256-gcm', this.key, nonce);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([
|
||||
decipher.update(bytes.subarray(1 + NONCE_SIZE, -TAG_SIZE)),
|
||||
decipher.update(bytes.subarray(VERSION_SIZE_BYTES + NONCE_SIZE, -TAG_SIZE)),
|
||||
decipher.final(),
|
||||
]).toString('utf8');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user