From c18e2b9d922a5e446ddc18cef62093a7455186f4 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Fri, 16 Oct 2026 22:53:32 +0300 Subject: [PATCH] fix: translate permanent invalid-token errors on online entrypoints (WealthLedger/backend#59) --- .../tbank/tbank-access.service.ts | 21 ++--- .../application/catalog-discovery.service.ts | 64 +++++++------- .../application/catalog-enrichment.service.ts | 27 +++--- .../stock-screener.service.spec.ts | 6 +- .../application/stock-screener.service.ts | 10 ++- test/bonds.e2e-spec.ts | 44 ++++++++++ test/online-upstream.ts | 87 +++++++++++++++++++ test/stocks.e2e-spec.ts | 45 ++++++++++ 8 files changed, 240 insertions(+), 64 deletions(-) create mode 100644 test/online-upstream.ts diff --git a/src/integrations/tbank/tbank-access.service.ts b/src/integrations/tbank/tbank-access.service.ts index 7d77079..06e22d2 100644 --- a/src/integrations/tbank/tbank-access.service.ts +++ b/src/integrations/tbank/tbank-access.service.ts @@ -121,19 +121,11 @@ export class TbankAccessService { return work ? this.tokens.decrypt(work.connection.encryptedToken) : null; } - async onlineToken(userId: string) { - const connection = await this.prisma.tbankConnection.findUnique({ - where: { userId }, - select: { encryptedToken: true, invalidToken: true }, - }); - if (!connection) throw new ConflictException({ code: TbankErrorCode.NotConnected }); - if (connection.invalidToken) { - throw new BadRequestException({ code: TbankErrorCode.InvalidToken }); - } - return this.tokens.decrypt(connection.encryptedToken); - } - - async current(userId: string): Promise<{ revision: string; token: string }> { + // Online reads: a permanent upstream auth refusal becomes 400/INVALID_TBANK_TOKEN here, once, for every caller. + async online( + userId: string, + run: (connection: { revision: string; token: string }) => Promise, + ): Promise { const connection = await this.prisma.tbankConnection.findUnique({ where: { userId }, select: { encryptedToken: true, invalidToken: true, revision: true }, @@ -142,7 +134,8 @@ export class TbankAccessService { if (connection.invalidToken) { throw new BadRequestException({ code: TbankErrorCode.InvalidToken }); } - return { revision: connection.revision, token: this.tokens.decrypt(connection.encryptedToken) }; + const token = this.tokens.decrypt(connection.encryptedToken); + return this.tokens.translateInvalidToken(() => run({ revision: connection.revision, token })); } async publishMembership( diff --git a/src/modules/investments/instruments/application/catalog-discovery.service.ts b/src/modules/investments/instruments/application/catalog-discovery.service.ts index 8202b42..bce90ce 100644 --- a/src/modules/investments/instruments/application/catalog-discovery.service.ts +++ b/src/modules/investments/instruments/application/catalog-discovery.service.ts @@ -122,11 +122,11 @@ export class CatalogDiscoveryService { observe = false, ) { if (!observe) { - const token = await this.access.onlineToken(userId); - const instrument = + const instrument = await this.access.online(userId, ({ token }) => instrumentType === 'share' - ? await this.tbank.getShare(token, uid) - : await this.tbank.getBond(token, uid); + ? this.tbank.getShare(token, uid) + : this.tbank.getBond(token, uid), + ); if (!instrument) throw new NotFoundException(); return { data: { ...publicInstrument({ ...instrument, uid }), uid, slug: instrument.ticker?.toUpperCase() ?? uid }, @@ -201,11 +201,11 @@ export class CatalogDiscoveryService { ) { if (!observe) { const query = ticker.trim().toUpperCase(); - const token = await this.access.onlineToken(userId); - const found = + const found = await this.access.online(userId, ({ token }) => instrumentType === 'share' - ? await this.tbank.findShares(token, query) - : await this.tbank.findBonds(token, query); + ? this.tbank.findShares(token, query) + : this.tbank.findBonds(token, query), + ); const matches = selectCatalogInstruments( found.filter((item) => item.ticker?.trim().toUpperCase() === query), query, @@ -268,30 +268,32 @@ export class CatalogDiscoveryService { } private async onlineSearch(userId: string, input: CatalogQuery) { - const token = await this.access.onlineToken(userId); - const found = - input.instrumentType === 'share' - ? await this.tbank.findShares(token, input.query) - : await this.tbank.findBonds(token, input.query); - const instruments = selectCatalogInstruments( - input.exactTicker - ? found.filter((item) => item.ticker?.trim().toUpperCase() === input.query) - : found, - input.query, - input.instrumentType, - ).slice(FIRST_RESULT_OFFSET, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit); - const [details, prices] = await Promise.all([ - Promise.all( - instruments.map((item) => - input.instrumentType === 'share' - ? this.tbank.getShare(token, item.uid) - : this.tbank.getBond(token, item.uid), + const { details, instruments, prices } = await this.access.online(userId, async ({ token }) => { + const found = + input.instrumentType === 'share' + ? await this.tbank.findShares(token, input.query) + : await this.tbank.findBonds(token, input.query); + const instruments = selectCatalogInstruments( + input.exactTicker + ? found.filter((item) => item.ticker?.trim().toUpperCase() === input.query) + : found, + input.query, + input.instrumentType, + ).slice(FIRST_RESULT_OFFSET, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit); + const [details, prices] = await Promise.all([ + Promise.all( + instruments.map((item) => + input.instrumentType === 'share' + ? this.tbank.getShare(token, item.uid) + : this.tbank.getBond(token, item.uid), + ), ), - ), - instruments.length - ? this.tbank.getLastPrices(token, instruments.map((item) => item.uid)) - : [], - ]); + instruments.length + ? this.tbank.getLastPrices(token, instruments.map((item) => item.uid)) + : [], + ]); + return { details, instruments, prices }; + }); const fetchedAt = new Date(); const data = instruments.flatMap((item, index) => { const detail = details.at(index); diff --git a/src/modules/investments/instruments/application/catalog-enrichment.service.ts b/src/modules/investments/instruments/application/catalog-enrichment.service.ts index 3acb074..1bfa0ae 100644 --- a/src/modules/investments/instruments/application/catalog-enrichment.service.ts +++ b/src/modules/investments/instruments/application/catalog-enrichment.service.ts @@ -231,7 +231,6 @@ export class CatalogEnrichmentService { return reservation; } - // eslint-disable-next-line sonarjs/cognitive-complexity -- Section composition owns the source dependency policy. async section( userId: string, instrumentType: 'bond' | 'share', @@ -240,18 +239,20 @@ export class CatalogEnrichmentService { observe = false, ) { if (section === 'company' && !observe) { - const token = await this.access.onlineToken(userId); - const instrument = - instrumentType === 'share' - ? await this.tbank.getShare(token, uid) - : await this.tbank.getBond(token, uid); - if (!instrument) throw new NotFoundException(); - const [description, fundamentals] = instrument.assetUid - ? await Promise.all([ - this.tbank.getAssetDescription(token, instrument.assetUid), - this.tbank.getFundamentals(token, instrument.assetUid), - ]) - : [null, null]; + const { description, fundamentals, instrument } = await this.access.online(userId, async ({ token }) => { + const instrument = + instrumentType === 'share' + ? await this.tbank.getShare(token, uid) + : await this.tbank.getBond(token, uid); + if (!instrument) throw new NotFoundException(); + const [description, fundamentals] = instrument.assetUid + ? await Promise.all([ + this.tbank.getAssetDescription(token, instrument.assetUid), + this.tbank.getFundamentals(token, instrument.assetUid), + ]) + : [null, null]; + return { description, fundamentals, instrument }; + }); const meta = investmentResourceMeta({ resourceKey: `${instrumentType}/company/${uid}`, dataRevision: `online:${Date.now()}`, diff --git a/src/modules/investments/instruments/stocks/application/stock-screener.service.spec.ts b/src/modules/investments/instruments/stocks/application/stock-screener.service.spec.ts index f933873..34b3323 100644 --- a/src/modules/investments/instruments/stocks/application/stock-screener.service.spec.ts +++ b/src/modules/investments/instruments/stocks/application/stock-screener.service.spec.ts @@ -22,14 +22,16 @@ function share(index: number, overrides: Partial = {}): TbankShare { } describe('StockScreenerService', () => { - const access = { current: jest.fn(), local: jest.fn() }; + const access = { online: jest.fn(), local: jest.fn() }; const client = { getAllShares: jest.fn() }; const logger = { event: jest.fn() }; let service: StockScreenerService; beforeEach(() => { jest.clearAllMocks(); - access.current.mockResolvedValue({ revision, token }); + access.online.mockImplementation((_userId: string, run: (connection: { revision: string; token: string }) => Promise) => + run({ revision, token }), + ); access.local.mockResolvedValue({ revision }); client.getAllShares.mockResolvedValue( Array.from({ length: pageSize + 1 }, (_, index) => share(index)), diff --git a/src/modules/investments/instruments/stocks/application/stock-screener.service.ts b/src/modules/investments/instruments/stocks/application/stock-screener.service.ts index a566bb6..e069ff2 100644 --- a/src/modules/investments/instruments/stocks/application/stock-screener.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-screener.service.ts @@ -48,8 +48,8 @@ type StockScreenerPage = { }; type Snapshot = { asOf: string; expiresAt: number; items: StockScreenerItem[]; revision: string; userId: string }; type TbankAccess = { - current(userId: string): Promise<{ revision: string; token: string }>; local(userId: string): Promise<{ revision: string }>; + online(userId: string, run: (connection: { revision: string; token: string }) => Promise): Promise; }; @Injectable() @@ -63,8 +63,10 @@ export class StockScreenerService { ) {} async search(userId: string, conditions: StockScreenerConditions): Promise { - const connection = await this.access.current(userId); - const instruments = await this.client.getAllShares(connection.token); + const { instruments, revision } = await this.access.online(userId, async (connection) => ({ + instruments: await this.client.getAllShares(connection.token), + revision: connection.revision, + })); const asOf = new Date().toISOString(); const selected = selectStockScreenerInstruments({ asOf: new Date(asOf), instruments }, conditions) .instruments.map((instrument) => this.item(instrument)); @@ -74,7 +76,7 @@ export class StockScreenerService { asOf, expiresAt: Date.now() + SNAPSHOT_TTL_MS, items: selected, - revision: connection.revision, + revision, userId, }); this.logger.event('info', 'stock.screener.search.completed', { asOf, count: selected.length, operation: 'stock-screener-search' }); diff --git a/test/bonds.e2e-spec.ts b/test/bonds.e2e-spec.ts index 0b464d8..246a97c 100644 --- a/test/bonds.e2e-spec.ts +++ b/test/bonds.e2e-spec.ts @@ -1,5 +1,6 @@ import type { Server } from 'node:http'; +import { status } from '@grpc/grpc-js'; import { ConflictException, INestApplication, @@ -16,6 +17,13 @@ import { BondCatalogService } from '../src/modules/investments/instruments/bonds import { BondIncomeService } from '../src/modules/investments/instruments/bonds/application/bond-income.service'; import { BondMarketService } from '../src/modules/investments/instruments/bonds/application/bond-market.service'; import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler'; +import { + bootOnlineApp, + expectSafeBody, + failUpstream, + onlineUserId, + refusals, +} from './online-upstream'; const uid = 'e6123145-9665-43e0-8413-cd61b8aa9b13'; describe('Bond HTTP contract', () => { @@ -201,3 +209,39 @@ describe('Bond HTTP contract', () => { expect(response.body).not.toHaveProperty('stack'); }); }); + +// Real services and TbankClient error mapping; only the gRPC transport is stubbed. +// Bonds expose no company or screener route; their online entrypoints are search, detail and ticker. +describe('Bond online reads: upstream failure translation', () => { + const rpc = jest.fn(); + let ctx: Awaited>; + const get = (path: string) => request(ctx.app.getHttpServer()).get(path); + // [entrypoint, request, RPC that fails second]: a failure after the first call must translate too. + const entrypoints: [string, () => request.Test, string][] = [ + ['search', () => get('/api/v1/bonds?query=SBER'), 'getLastPrices'], + ['detail', () => get(`/api/v1/bonds/${uid}`), 'bondBy'], + ['ticker', () => get('/api/v1/bonds/by-ticker/SBER'), 'bondBy'], + ]; + const cases = entrypoints.flatMap(([name, send, later]) => + refusals.flatMap((code) => [undefined, later].map((at) => [name, code, at, send] as const)), + ); + + beforeAll(async () => { + ctx = await bootOnlineApp(rpc); + }); + afterAll(() => ctx.app.close()); + + it.each(cases)('%s: upstream auth refusal %s (failing RPC %s) becomes 400/INVALID_TBANK_TOKEN', async (_name, code, at, send) => { + failUpstream(rpc, 'bond', code, at); + const response = await send().set('Authorization', ctx.bearer).expect(400); + expectSafeBody(response.body, 400, 'INVALID_TBANK_TOKEN'); + const saved = await ctx.prisma.tbankConnection.findUniqueOrThrow({ where: { userId: onlineUserId } }); + expect(saved).toMatchObject({ invalidToken: false, revision: ctx.connection.revision }); + }); + + it.each(entrypoints)('%s: temporary unavailability stays 503/TBANK_UNAVAILABLE', async (_name, send) => { + failUpstream(rpc, 'bond', status.UNAVAILABLE); + const response = await send().set('Authorization', ctx.bearer).expect(503); + expectSafeBody(response.body, 503, 'TBANK_UNAVAILABLE'); + }); +}); diff --git a/test/online-upstream.ts b/test/online-upstream.ts new file mode 100644 index 0000000..293d3fe --- /dev/null +++ b/test/online-upstream.ts @@ -0,0 +1,87 @@ +import type { Server } from 'node:http'; + +import { status } from '@grpc/grpc-js'; +import { INestApplication } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { Test } from '@nestjs/testing'; + +import { AppModule } from '../src/app.module'; +import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; +import { TbankClient } from '../src/integrations/tbank/tbank.client'; +import { configureApp } from '../src/main'; +import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler'; +import { PrismaService } from '../src/prisma/prisma.service'; + +export const onlineUserId = 'online-reader'; +export const onlineToken = 'synthetic-online-token'; +export const rawUpstreamText = 'raw upstream refusal text'; +export const refusals = [status.UNAUTHENTICATED, status.PERMISSION_DENIED]; + +// Real TbankClient with only the transport stubbed: the gRPC status goes through its real error mapping. +export async function bootOnlineApp(rpc: jest.Mock) { + const upstream = Object.assign(Object.create(TbankClient.prototype) as TbankClient, { + logger: { event: jest.fn() }, + queue: { run: (operation: () => Promise) => operation(), size: 0 }, + request: rpc, + }); + const module = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(TbankClient) + .useValue(upstream) + .overrideProvider(TbankHourlyScheduler) + .useValue({ tick: jest.fn() }) + .compile(); + const app: INestApplication = module.createNestApplication(); + configureApp(app); + await app.init(); + await app.listen(0, '127.0.0.1'); + const prisma = app.get(PrismaService); + await prisma.user.create({ + data: { id: onlineUserId, email: 'online@example.invalid', passwordHash: 'synthetic' }, + }); + const connection = await prisma.tbankConnection.create({ + data: { + userId: onlineUserId, + encryptedToken: app.get(TbankTokenService).encrypt(onlineToken), + connectedAt: new Date(), + }, + }); + const bearer = `Bearer ${new JwtService().sign( + { sub: onlineUserId, type: 'access' }, + { secret: process.env.JWT_SECRET }, + )}`; + return { app, bearer, connection, prisma }; +} + +// Healthy answers for every RPC an online entrypoint uses; `at` picks the single RPC that fails. +export function failUpstream(rpc: jest.Mock, kind: 'bond' | 'share', code: number, at?: string) { + const instrument = { + apiTradeAvailableFlag: true, + assetUid: 'asset-uid', + instrumentKind: kind === 'bond' ? 'INSTRUMENT_TYPE_BOND' : 'INSTRUMENT_TYPE_SHARE', + ticker: 'SBER', + uid: 'e6123145-9665-43e0-8413-cd61b8aa9b13', + }; + const healthy = new Map([ + ['bondBy', { instrument }], + ['findInstrument', { instruments: [instrument] }], + ['getAssetBy', { asset: { description: 'description' } }], + ['getAssetFundamentals', { fundamentals: [] }], + ['getLastPrices', { lastPrices: [] }], + ['shareBy', { instrument }], + ]); + const failure = Object.assign(new Error('grpc failure'), { + code, + details: `${rawUpstreamText} Bearer ${onlineToken}`, + }); + rpc.mockReset(); + rpc.mockImplementation((_token: string, _client: unknown, method: string) => + !at || method === at ? Promise.reject(failure) : Promise.resolve(healthy.get(method) ?? {}), + ); +} + +// Safe public error: stable code only, no upstream text, no credentials. +export function expectSafeBody(body: unknown, status: number, code: string) { + expect(body).toEqual({ code, status }); + expect(JSON.stringify(body)).not.toContain(rawUpstreamText); + expect(JSON.stringify(body)).not.toContain(onlineToken); +} diff --git a/test/stocks.e2e-spec.ts b/test/stocks.e2e-spec.ts index abf3fe7..ca1b8c0 100644 --- a/test/stocks.e2e-spec.ts +++ b/test/stocks.e2e-spec.ts @@ -1,5 +1,6 @@ import type { Server } from 'node:http'; +import { status } from '@grpc/grpc-js'; import { ConflictException, INestApplication, @@ -17,6 +18,13 @@ import { StockIncomeService } from '../src/modules/investments/instruments/stock import { StockMarketService } from '../src/modules/investments/instruments/stocks/application/stock-market.service'; import { StockScreenerService } from '../src/modules/investments/instruments/stocks/application/stock-screener.service'; import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler'; +import { + bootOnlineApp, + expectSafeBody, + failUpstream, + onlineUserId, + refusals, +} from './online-upstream'; const uid = 'e6123145-9665-43e0-8413-cd61b8aa9b13'; describe('Stock HTTP contract', () => { @@ -201,3 +209,40 @@ describe('Stock HTTP contract', () => { expect(response.body).not.toHaveProperty('stack'); }); }); + +// Real services and TbankClient error mapping; only the gRPC transport is stubbed. +describe('Stock online reads: upstream failure translation', () => { + const rpc = jest.fn(); + let ctx: Awaited>; + const get = (path: string) => request(ctx.app.getHttpServer()).get(path); + // [entrypoint, request, RPC that fails second]: a failure after the first call must translate too. + const entrypoints: [string, () => request.Test, string][] = [ + ['search', () => get('/api/v1/stocks?query=SBER'), 'getLastPrices'], + ['detail', () => get(`/api/v1/stocks/${uid}`), 'shareBy'], + ['ticker', () => get('/api/v1/stocks/by-ticker/SBER'), 'shareBy'], + ['company', () => get(`/api/v1/stocks/${uid}/company`), 'getAssetBy'], + ['screener', () => request(ctx.app.getHttpServer()).post('/api/v1/stocks/screener/search').send({}), 'shares'], + ]; + const cases = entrypoints.flatMap(([name, send, later]) => + refusals.flatMap((code) => [undefined, later].map((at) => [name, code, at, send] as const)), + ); + + beforeAll(async () => { + ctx = await bootOnlineApp(rpc); + }); + afterAll(() => ctx.app.close()); + + it.each(cases)('%s: upstream auth refusal %s (failing RPC %s) becomes 400/INVALID_TBANK_TOKEN', async (_name, code, at, send) => { + failUpstream(rpc, 'share', code, at); + const response = await send().set('Authorization', ctx.bearer).expect(400); + expectSafeBody(response.body, 400, 'INVALID_TBANK_TOKEN'); + const saved = await ctx.prisma.tbankConnection.findUniqueOrThrow({ where: { userId: onlineUserId } }); + expect(saved).toMatchObject({ invalidToken: false, revision: ctx.connection.revision }); + }); + + it.each(entrypoints)('%s: temporary unavailability stays 503/TBANK_UNAVAILABLE', async (_name, send) => { + failUpstream(rpc, 'share', status.UNAVAILABLE); + const response = await send().set('Authorization', ctx.bearer).expect(503); + expectSafeBody(response.body, 503, 'TBANK_UNAVAILABLE'); + }); +}); -- 2.54.0