fix: translate permanent invalid-token errors on online entrypoints (WealthLedger/backend#59) #110

Merged
codex-bot merged 1 commits from fix/59-online-invalid-token-translation into develop 2026-10-16 23:23:12 +03:00
8 changed files with 240 additions and 64 deletions
+7 -14
View File
@@ -121,19 +121,11 @@ export class TbankAccessService {
return work ? this.tokens.decrypt(work.connection.encryptedToken) : null; return work ? this.tokens.decrypt(work.connection.encryptedToken) : null;
} }
async onlineToken(userId: string) { // Online reads: a permanent upstream auth refusal becomes 400/INVALID_TBANK_TOKEN here, once, for every caller.
const connection = await this.prisma.tbankConnection.findUnique({ async online<T>(
where: { userId }, userId: string,
select: { encryptedToken: true, invalidToken: true }, run: (connection: { revision: string; token: string }) => Promise<T>,
}); ): Promise<T> {
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 }> {
const connection = await this.prisma.tbankConnection.findUnique({ const connection = await this.prisma.tbankConnection.findUnique({
where: { userId }, where: { userId },
select: { encryptedToken: true, invalidToken: true, revision: true }, select: { encryptedToken: true, invalidToken: true, revision: true },
@@ -142,7 +134,8 @@ export class TbankAccessService {
if (connection.invalidToken) { if (connection.invalidToken) {
throw new BadRequestException({ code: TbankErrorCode.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( async publishMembership(
@@ -122,11 +122,11 @@ export class CatalogDiscoveryService {
observe = false, observe = false,
) { ) {
if (!observe) { if (!observe) {
const token = await this.access.onlineToken(userId); const instrument = await this.access.online(userId, ({ token }) =>
const instrument =
instrumentType === 'share' instrumentType === 'share'
? await this.tbank.getShare(token, uid) ? this.tbank.getShare(token, uid)
: await this.tbank.getBond(token, uid); : this.tbank.getBond(token, uid),
);
if (!instrument) throw new NotFoundException(); if (!instrument) throw new NotFoundException();
return { return {
data: { ...publicInstrument({ ...instrument, uid }), uid, slug: instrument.ticker?.toUpperCase() ?? uid }, data: { ...publicInstrument({ ...instrument, uid }), uid, slug: instrument.ticker?.toUpperCase() ?? uid },
@@ -201,11 +201,11 @@ export class CatalogDiscoveryService {
) { ) {
if (!observe) { if (!observe) {
const query = ticker.trim().toUpperCase(); const query = ticker.trim().toUpperCase();
const token = await this.access.onlineToken(userId); const found = await this.access.online(userId, ({ token }) =>
const found =
instrumentType === 'share' instrumentType === 'share'
? await this.tbank.findShares(token, query) ? this.tbank.findShares(token, query)
: await this.tbank.findBonds(token, query); : this.tbank.findBonds(token, query),
);
const matches = selectCatalogInstruments( const matches = selectCatalogInstruments(
found.filter((item) => item.ticker?.trim().toUpperCase() === query), found.filter((item) => item.ticker?.trim().toUpperCase() === query),
query, query,
@@ -268,30 +268,32 @@ export class CatalogDiscoveryService {
} }
private async onlineSearch(userId: string, input: CatalogQuery) { private async onlineSearch(userId: string, input: CatalogQuery) {
const token = await this.access.onlineToken(userId); const { details, instruments, prices } = await this.access.online(userId, async ({ token }) => {
const found = const found =
input.instrumentType === 'share' input.instrumentType === 'share'
? await this.tbank.findShares(token, input.query) ? await this.tbank.findShares(token, input.query)
: await this.tbank.findBonds(token, input.query); : await this.tbank.findBonds(token, input.query);
const instruments = selectCatalogInstruments( const instruments = selectCatalogInstruments(
input.exactTicker input.exactTicker
? found.filter((item) => item.ticker?.trim().toUpperCase() === input.query) ? found.filter((item) => item.ticker?.trim().toUpperCase() === input.query)
: found, : found,
input.query, input.query,
input.instrumentType, input.instrumentType,
).slice(FIRST_RESULT_OFFSET, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit); ).slice(FIRST_RESULT_OFFSET, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit);
const [details, prices] = await Promise.all([ const [details, prices] = await Promise.all([
Promise.all( Promise.all(
instruments.map((item) => instruments.map((item) =>
input.instrumentType === 'share' input.instrumentType === 'share'
? this.tbank.getShare(token, item.uid) ? this.tbank.getShare(token, item.uid)
: this.tbank.getBond(token, item.uid), : this.tbank.getBond(token, item.uid),
),
), ),
), instruments.length
instruments.length ? this.tbank.getLastPrices(token, instruments.map((item) => item.uid))
? this.tbank.getLastPrices(token, instruments.map((item) => item.uid)) : [],
: [], ]);
]); return { details, instruments, prices };
});
const fetchedAt = new Date(); const fetchedAt = new Date();
const data = instruments.flatMap((item, index) => { const data = instruments.flatMap((item, index) => {
const detail = details.at(index); const detail = details.at(index);
@@ -231,7 +231,6 @@ export class CatalogEnrichmentService {
return reservation; return reservation;
} }
// eslint-disable-next-line sonarjs/cognitive-complexity -- Section composition owns the source dependency policy.
async section( async section(
userId: string, userId: string,
instrumentType: 'bond' | 'share', instrumentType: 'bond' | 'share',
@@ -240,18 +239,20 @@ export class CatalogEnrichmentService {
observe = false, observe = false,
) { ) {
if (section === 'company' && !observe) { if (section === 'company' && !observe) {
const token = await this.access.onlineToken(userId); const { description, fundamentals, instrument } = await this.access.online(userId, async ({ token }) => {
const instrument = const instrument =
instrumentType === 'share' instrumentType === 'share'
? await this.tbank.getShare(token, uid) ? await this.tbank.getShare(token, uid)
: await this.tbank.getBond(token, uid); : await this.tbank.getBond(token, uid);
if (!instrument) throw new NotFoundException(); if (!instrument) throw new NotFoundException();
const [description, fundamentals] = instrument.assetUid const [description, fundamentals] = instrument.assetUid
? await Promise.all([ ? await Promise.all([
this.tbank.getAssetDescription(token, instrument.assetUid), this.tbank.getAssetDescription(token, instrument.assetUid),
this.tbank.getFundamentals(token, instrument.assetUid), this.tbank.getFundamentals(token, instrument.assetUid),
]) ])
: [null, null]; : [null, null];
return { description, fundamentals, instrument };
});
const meta = investmentResourceMeta({ const meta = investmentResourceMeta({
resourceKey: `${instrumentType}/company/${uid}`, resourceKey: `${instrumentType}/company/${uid}`,
dataRevision: `online:${Date.now()}`, dataRevision: `online:${Date.now()}`,
@@ -22,14 +22,16 @@ function share(index: number, overrides: Partial<TbankShare> = {}): TbankShare {
} }
describe('StockScreenerService', () => { describe('StockScreenerService', () => {
const access = { current: jest.fn(), local: jest.fn() }; const access = { online: jest.fn(), local: jest.fn() };
const client = { getAllShares: jest.fn() }; const client = { getAllShares: jest.fn() };
const logger = { event: jest.fn() }; const logger = { event: jest.fn() };
let service: StockScreenerService; let service: StockScreenerService;
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
access.current.mockResolvedValue({ revision, token }); access.online.mockImplementation((_userId: string, run: (connection: { revision: string; token: string }) => Promise<unknown>) =>
run({ revision, token }),
);
access.local.mockResolvedValue({ revision }); access.local.mockResolvedValue({ revision });
client.getAllShares.mockResolvedValue( client.getAllShares.mockResolvedValue(
Array.from({ length: pageSize + 1 }, (_, index) => share(index)), Array.from({ length: pageSize + 1 }, (_, index) => share(index)),
@@ -48,8 +48,8 @@ type StockScreenerPage = {
}; };
type Snapshot = { asOf: string; expiresAt: number; items: StockScreenerItem[]; revision: string; userId: string }; type Snapshot = { asOf: string; expiresAt: number; items: StockScreenerItem[]; revision: string; userId: string };
type TbankAccess = { type TbankAccess = {
current(userId: string): Promise<{ revision: string; token: string }>;
local(userId: string): Promise<{ revision: string }>; local(userId: string): Promise<{ revision: string }>;
online<T>(userId: string, run: (connection: { revision: string; token: string }) => Promise<T>): Promise<T>;
}; };
@Injectable() @Injectable()
@@ -63,8 +63,10 @@ export class StockScreenerService {
) {} ) {}
async search(userId: string, conditions: StockScreenerConditions): Promise<StockScreenerPage> { async search(userId: string, conditions: StockScreenerConditions): Promise<StockScreenerPage> {
const connection = await this.access.current(userId); const { instruments, revision } = await this.access.online(userId, async (connection) => ({
const instruments = await this.client.getAllShares(connection.token); instruments: await this.client.getAllShares(connection.token),
revision: connection.revision,
}));
const asOf = new Date().toISOString(); const asOf = new Date().toISOString();
const selected = selectStockScreenerInstruments({ asOf: new Date(asOf), instruments }, conditions) const selected = selectStockScreenerInstruments({ asOf: new Date(asOf), instruments }, conditions)
.instruments.map((instrument) => this.item(instrument)); .instruments.map((instrument) => this.item(instrument));
@@ -74,7 +76,7 @@ export class StockScreenerService {
asOf, asOf,
expiresAt: Date.now() + SNAPSHOT_TTL_MS, expiresAt: Date.now() + SNAPSHOT_TTL_MS,
items: selected, items: selected,
revision: connection.revision, revision,
userId, userId,
}); });
this.logger.event('info', 'stock.screener.search.completed', { asOf, count: selected.length, operation: 'stock-screener-search' }); this.logger.event('info', 'stock.screener.search.completed', { asOf, count: selected.length, operation: 'stock-screener-search' });
+44
View File
@@ -1,5 +1,6 @@
import type { Server } from 'node:http'; import type { Server } from 'node:http';
import { status } from '@grpc/grpc-js';
import { import {
ConflictException, ConflictException,
INestApplication, 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 { BondIncomeService } from '../src/modules/investments/instruments/bonds/application/bond-income.service';
import { BondMarketService } from '../src/modules/investments/instruments/bonds/application/bond-market.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 { 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'; const uid = 'e6123145-9665-43e0-8413-cd61b8aa9b13';
describe('Bond HTTP contract', () => { describe('Bond HTTP contract', () => {
@@ -201,3 +209,39 @@ describe('Bond HTTP contract', () => {
expect(response.body).not.toHaveProperty('stack'); 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<ReturnType<typeof bootOnlineApp>>;
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');
});
});
+87
View File
@@ -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<unknown>) => 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<Server> = 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<string, unknown>([
['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);
}
+45
View File
@@ -1,5 +1,6 @@
import type { Server } from 'node:http'; import type { Server } from 'node:http';
import { status } from '@grpc/grpc-js';
import { import {
ConflictException, ConflictException,
INestApplication, 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 { StockMarketService } from '../src/modules/investments/instruments/stocks/application/stock-market.service';
import { StockScreenerService } from '../src/modules/investments/instruments/stocks/application/stock-screener.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 { 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'; const uid = 'e6123145-9665-43e0-8413-cd61b8aa9b13';
describe('Stock HTTP contract', () => { describe('Stock HTTP contract', () => {
@@ -201,3 +209,40 @@ describe('Stock HTTP contract', () => {
expect(response.body).not.toHaveProperty('stack'); 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<ReturnType<typeof bootOnlineApp>>;
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');
});
});