fix(tbank): isolate caches by connection generation
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
ServiceUnavailableException,
|
ServiceUnavailableException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -25,12 +26,14 @@ describe('BrokerageOperationsService', () => {
|
|||||||
metadata as never,
|
metadata as never,
|
||||||
);
|
);
|
||||||
let operationsRequest: unknown;
|
let operationsRequest: unknown;
|
||||||
|
let generation: string;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
access.withAccount.mockReset();
|
access.withAccount.mockReset();
|
||||||
tbank.getOperationsByCursor.mockReset();
|
tbank.getOperationsByCursor.mockReset();
|
||||||
metadata.resolve.mockReset();
|
metadata.resolve.mockReset();
|
||||||
|
generation = 'generation-1';
|
||||||
access.withAccount.mockImplementation(
|
access.withAccount.mockImplementation(
|
||||||
(
|
(
|
||||||
_userId: string,
|
_userId: string,
|
||||||
@@ -39,6 +42,7 @@ describe('BrokerageOperationsService', () => {
|
|||||||
) =>
|
) =>
|
||||||
handler({
|
handler({
|
||||||
token: 'token',
|
token: 'token',
|
||||||
|
generation,
|
||||||
account: { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: '' },
|
account: { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: '' },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -254,6 +258,54 @@ describe('BrokerageOperationsService', () => {
|
|||||||
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(1);
|
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not reuse a cached ledger page across connection generations', async () => {
|
||||||
|
const query = { from: '2025-01-01', to: '2025-01-31' };
|
||||||
|
|
||||||
|
await service.get('user', 'account', query);
|
||||||
|
await service.get('user', 'account', query);
|
||||||
|
generation = 'generation-2';
|
||||||
|
await service.get('user', 'account', query);
|
||||||
|
|
||||||
|
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not reuse a cached ledger page after the connection is deleted', async () => {
|
||||||
|
const query = { from: '2025-01-01', to: '2025-01-31' };
|
||||||
|
await service.get('user', 'account', query);
|
||||||
|
access.withAccount.mockRejectedValueOnce(
|
||||||
|
new ConflictException({ code: 'TBANK_NOT_CONNECTED' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(service.get('user', 'account', query)).rejects.toEqual(
|
||||||
|
expect.any(ConflictException),
|
||||||
|
);
|
||||||
|
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a late previous-generation page unavailable to ledger and income collection', async () => {
|
||||||
|
let finishPrevious: ((value: unknown) => void) | undefined;
|
||||||
|
const previous = new Promise((resolve) => {
|
||||||
|
finishPrevious = resolve;
|
||||||
|
});
|
||||||
|
tbank.getOperationsByCursor
|
||||||
|
.mockReturnValueOnce(previous)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
items: [operation('current')],
|
||||||
|
});
|
||||||
|
const range = { from: '2025-01-01', to: '2025-01-31' };
|
||||||
|
|
||||||
|
const previousRequest = service.collectRanges('user', 'account', [range]);
|
||||||
|
generation = 'generation-2';
|
||||||
|
const current = await service.collectRanges('user', 'account', [range]);
|
||||||
|
finishPrevious?.({ items: [operation('previous')] });
|
||||||
|
await previousRequest;
|
||||||
|
const reused = await service.collectRanges('user', 'account', [range]);
|
||||||
|
|
||||||
|
expect(current.pages[0][0].items[0].id).toBe('current');
|
||||||
|
expect(reused.pages[0][0].items[0].id).toBe('current');
|
||||||
|
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
it('collects every cursor page and rejects a malformed continuation cursor', async () => {
|
it('collects every cursor page and rejects a malformed continuation cursor', async () => {
|
||||||
tbank.getOperationsByCursor
|
tbank.getOperationsByCursor
|
||||||
.mockResolvedValueOnce({ hasNext: true, nextCursor: 'next', items: [] })
|
.mockResolvedValueOnce({ hasNext: true, nextCursor: 'next', items: [] })
|
||||||
@@ -311,3 +363,11 @@ describe('BrokerageOperationsService', () => {
|
|||||||
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(102);
|
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(102);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function operation(id: string) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
date: { seconds: '1' },
|
||||||
|
type: 'OPERATION_TYPE_COUPON',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ export class BrokerageOperationsService {
|
|||||||
this.requestContext.set({ userId, brokerageAccountId: accountId });
|
this.requestContext.set({ userId, brokerageAccountId: accountId });
|
||||||
const range = this.range(query);
|
const range = this.range(query);
|
||||||
|
|
||||||
return this.access.withAccount(userId, accountId, ({ token }) =>
|
return this.access.withAccount(userId, accountId, ({ token, generation }) =>
|
||||||
this.page(token, userId, accountId, query, range),
|
this.page(token, generation, userId, accountId, query, range),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ export class BrokerageOperationsService {
|
|||||||
return this.access.withAccount(
|
return this.access.withAccount(
|
||||||
userId,
|
userId,
|
||||||
accountId,
|
accountId,
|
||||||
async ({ token, account }) => {
|
async ({ token, generation, account }) => {
|
||||||
const accountOpenedAt = account.openedDate?.seconds
|
const accountOpenedAt = account.openedDate?.seconds
|
||||||
? new Date(Number(account.openedDate.seconds) * 1000)
|
? new Date(Number(account.openedDate.seconds) * 1000)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -69,7 +69,7 @@ export class BrokerageOperationsService {
|
|||||||
accountOpenedAt,
|
accountOpenedAt,
|
||||||
pages: await Promise.all(
|
pages: await Promise.all(
|
||||||
selected.map((range) =>
|
selected.map((range) =>
|
||||||
this.collectRange(token, userId, accountId, range),
|
this.collectRange(token, generation, userId, accountId, range),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
@@ -79,6 +79,7 @@ export class BrokerageOperationsService {
|
|||||||
|
|
||||||
private async collectRange(
|
private async collectRange(
|
||||||
token: string,
|
token: string,
|
||||||
|
generation: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
range: DateOnlyRange,
|
range: DateOnlyRange,
|
||||||
@@ -89,6 +90,7 @@ export class BrokerageOperationsService {
|
|||||||
do {
|
do {
|
||||||
const page = await this.page(
|
const page = await this.page(
|
||||||
token,
|
token,
|
||||||
|
generation,
|
||||||
userId,
|
userId,
|
||||||
accountId,
|
accountId,
|
||||||
{
|
{
|
||||||
@@ -111,12 +113,13 @@ export class BrokerageOperationsService {
|
|||||||
|
|
||||||
private async page(
|
private async page(
|
||||||
token: string,
|
token: string,
|
||||||
|
generation: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
query: BrokerageOperationsQueryDto,
|
query: BrokerageOperationsQueryDto,
|
||||||
range: { from: Date; to: Date },
|
range: { from: Date; to: Date },
|
||||||
): Promise<BrokerageOperationsPageDto> {
|
): Promise<BrokerageOperationsPageDto> {
|
||||||
const key = cacheKey(userId, accountId, query, range);
|
const key = cacheKey(generation, userId, accountId, query, range);
|
||||||
const cached = this.pages.get(key);
|
const cached = this.pages.get(key);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
if (range.from > range.to) {
|
if (range.from > range.to) {
|
||||||
@@ -193,6 +196,7 @@ export class BrokerageOperationsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cacheKey(
|
function cacheKey(
|
||||||
|
generation: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
query: BrokerageOperationsQueryDto,
|
query: BrokerageOperationsQueryDto,
|
||||||
@@ -203,6 +207,7 @@ function cacheKey(
|
|||||||
? range.to.toISOString()
|
? range.to.toISOString()
|
||||||
: Math.floor(range.to.valueOf() / freshForMs);
|
: Math.floor(range.to.valueOf() / freshForMs);
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
|
generation,
|
||||||
userId,
|
userId,
|
||||||
accountId,
|
accountId,
|
||||||
from: range.from.toISOString(),
|
from: range.from.toISOString(),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
ServiceUnavailableException,
|
ServiceUnavailableException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
|
||||||
@@ -9,10 +10,16 @@ jest.mock('./tbank-access.service', () => ({
|
|||||||
|
|
||||||
import { PortfolioService } from './portfolio.service';
|
import { PortfolioService } from './portfolio.service';
|
||||||
import { InstrumentMetadataService } from './instrument-metadata.service';
|
import { InstrumentMetadataService } from './instrument-metadata.service';
|
||||||
import type { TbankAccountContext } from './tbank-access.service';
|
import type {
|
||||||
|
TbankAccountContext,
|
||||||
|
TbankConnectionContext,
|
||||||
|
} from './tbank-access.service';
|
||||||
|
|
||||||
describe('PortfolioService', () => {
|
describe('PortfolioService', () => {
|
||||||
const access = { withAccount: jest.fn() };
|
const access = {
|
||||||
|
withConnection: jest.fn(),
|
||||||
|
withAccountForConnection: jest.fn(),
|
||||||
|
};
|
||||||
const tbank = {
|
const tbank = {
|
||||||
getPortfolio: jest.fn(),
|
getPortfolio: jest.fn(),
|
||||||
getPositions: jest.fn(),
|
getPositions: jest.fn(),
|
||||||
@@ -31,18 +38,26 @@ describe('PortfolioService', () => {
|
|||||||
requestContext as never,
|
requestContext as never,
|
||||||
metadata,
|
metadata,
|
||||||
);
|
);
|
||||||
|
let generation: string;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
service.invalidate('user');
|
service.invalidate('user');
|
||||||
access.withAccount.mockImplementation(
|
generation = 'generation-1';
|
||||||
|
access.withConnection.mockImplementation(
|
||||||
(
|
(
|
||||||
_userId: string,
|
_userId: string,
|
||||||
|
handler: (context: TbankConnectionContext) => Promise<unknown>,
|
||||||
|
) => handler({ token: 'token', generation }),
|
||||||
|
);
|
||||||
|
access.withAccountForConnection.mockImplementation(
|
||||||
|
(
|
||||||
|
connection: TbankConnectionContext,
|
||||||
_accountId: string,
|
_accountId: string,
|
||||||
handler: (context: TbankAccountContext) => Promise<unknown>,
|
handler: (context: TbankAccountContext) => Promise<unknown>,
|
||||||
) =>
|
) =>
|
||||||
handler({
|
handler({
|
||||||
token: 'token',
|
...connection,
|
||||||
account: { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: '' },
|
account: { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: '' },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -52,14 +67,12 @@ describe('PortfolioService', () => {
|
|||||||
|
|
||||||
it('resolves account-scoped access before loading upstream portfolio', async () => {
|
it('resolves account-scoped access before loading upstream portfolio', async () => {
|
||||||
await service.get('user', 'account');
|
await service.get('user', 'account');
|
||||||
|
await service.get('user', 'account');
|
||||||
|
|
||||||
expect(access.withAccount).toHaveBeenCalledWith(
|
expect(access.withConnection).toHaveBeenCalledTimes(2);
|
||||||
'user',
|
expect(access.withAccountForConnection).toHaveBeenCalledTimes(1);
|
||||||
'account',
|
expect(tbank.getPortfolio).toHaveBeenCalledTimes(1);
|
||||||
expect.any(Function),
|
expect(tbank.getPositions).toHaveBeenCalledTimes(1);
|
||||||
);
|
|
||||||
expect(tbank.getPortfolio).toHaveBeenCalledWith('token', 'account');
|
|
||||||
expect(tbank.getPositions).toHaveBeenCalledWith('token', 'account');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('normalizes upstream order, exact totals, metadata fallback, and cash', async () => {
|
it('normalizes upstream order, exact totals, metadata fallback, and cash', async () => {
|
||||||
@@ -174,15 +187,17 @@ describe('PortfolioService', () => {
|
|||||||
it('uses fresh snapshots, falls back only for temporary errors, and clears them', async () => {
|
it('uses fresh snapshots, falls back only for temporary errors, and clears them', async () => {
|
||||||
await service.get('user', 'account');
|
await service.get('user', 'account');
|
||||||
await service.get('user', 'account');
|
await service.get('user', 'account');
|
||||||
expect(access.withAccount).toHaveBeenCalledTimes(1);
|
expect(access.withConnection).toHaveBeenCalledTimes(2);
|
||||||
|
expect(access.withAccountForConnection).toHaveBeenCalledTimes(1);
|
||||||
|
expect(tbank.getPortfolio).toHaveBeenCalledTimes(1);
|
||||||
service.invalidate('user');
|
service.invalidate('user');
|
||||||
await service.get('user', 'account');
|
await service.get('user', 'account');
|
||||||
expect(access.withAccount).toHaveBeenCalledTimes(2);
|
expect(tbank.getPortfolio).toHaveBeenCalledTimes(2);
|
||||||
const snapshots = (
|
const snapshots = (
|
||||||
service as never as { snapshots: Map<string, { storedAt: number }> }
|
service as never as { snapshots: Map<string, { storedAt: number }> }
|
||||||
).snapshots;
|
).snapshots;
|
||||||
snapshots.get('user:account')!.storedAt = Date.now() - 30_001;
|
snapshots.get('user:account')!.storedAt = Date.now() - 30_001;
|
||||||
access.withAccount.mockRejectedValueOnce(
|
tbank.getPortfolio.mockRejectedValueOnce(
|
||||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
||||||
);
|
);
|
||||||
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
||||||
@@ -196,11 +211,80 @@ describe('PortfolioService', () => {
|
|||||||
service as never as { snapshots: Map<string, { storedAt: number }> }
|
service as never as { snapshots: Map<string, { storedAt: number }> }
|
||||||
).snapshots;
|
).snapshots;
|
||||||
snapshots.get('user:account')!.storedAt = Date.now() - 30_001;
|
snapshots.get('user:account')!.storedAt = Date.now() - 30_001;
|
||||||
access.withAccount.mockRejectedValueOnce(
|
access.withAccountForConnection.mockRejectedValueOnce(
|
||||||
new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }),
|
new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }),
|
||||||
);
|
);
|
||||||
await expect(service.get('user', 'account')).rejects.toEqual(
|
await expect(service.get('user', 'account')).rejects.toEqual(
|
||||||
expect.any(BadRequestException),
|
expect.any(BadRequestException),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not reuse a snapshot or fallback across connection generations', async () => {
|
||||||
|
await service.get('user', 'account');
|
||||||
|
const snapshots = (
|
||||||
|
service as never as { snapshots: Map<string, { storedAt: number }> }
|
||||||
|
).snapshots;
|
||||||
|
snapshots.get('user:account')!.storedAt = Date.now() - 30_001;
|
||||||
|
generation = 'generation-2';
|
||||||
|
tbank.getPortfolio.mockRejectedValueOnce(
|
||||||
|
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(service.get('user', 'account')).rejects.toEqual(
|
||||||
|
expect.any(ServiceUnavailableException),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps current-generation fallback when account resolution is temporarily unavailable', async () => {
|
||||||
|
await service.get('user', 'account');
|
||||||
|
const snapshots = (
|
||||||
|
service as never as { snapshots: Map<string, { storedAt: number }> }
|
||||||
|
).snapshots;
|
||||||
|
snapshots.get('user:account')!.storedAt = Date.now() - 30_001;
|
||||||
|
access.withAccountForConnection.mockRejectedValueOnce(
|
||||||
|
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
||||||
|
isStale: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not return a snapshot when the current connection is absent', async () => {
|
||||||
|
await service.get('user', 'account');
|
||||||
|
access.withConnection.mockRejectedValueOnce(
|
||||||
|
new ConflictException({ code: 'TBANK_NOT_CONNECTED' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(service.get('user', 'account')).rejects.toEqual(
|
||||||
|
expect.any(ConflictException),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not expose a late previous-generation portfolio snapshot', async () => {
|
||||||
|
let finishPrevious: ((value: { positions: never[] }) => void) | undefined;
|
||||||
|
const previous = new Promise<{ positions: never[] }>((resolve) => {
|
||||||
|
finishPrevious = resolve;
|
||||||
|
});
|
||||||
|
tbank.getPortfolio
|
||||||
|
.mockReturnValueOnce(previous)
|
||||||
|
.mockResolvedValueOnce({ positions: [] })
|
||||||
|
.mockResolvedValueOnce({ positions: [] });
|
||||||
|
tbank.getPositions
|
||||||
|
.mockResolvedValueOnce({ money: [{ currency: 'RUB', units: 1 }] })
|
||||||
|
.mockResolvedValueOnce({ money: [{ currency: 'USD', units: 2 }] })
|
||||||
|
.mockResolvedValueOnce({ money: [{ currency: 'USD', units: 3 }] });
|
||||||
|
|
||||||
|
const previousRequest = service.get('user', 'account');
|
||||||
|
generation = 'generation-2';
|
||||||
|
const current = await service.get('user', 'account');
|
||||||
|
finishPrevious?.({ positions: [] });
|
||||||
|
await previousRequest;
|
||||||
|
const afterLateCompletion = await service.get('user', 'account');
|
||||||
|
|
||||||
|
expect(current.cashBalances).toEqual([{ currency: 'USD', value: '2' }]);
|
||||||
|
expect(afterLateCompletion.cashBalances).toEqual([
|
||||||
|
{ currency: 'USD', value: '3' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ const types: Record<string, string> = {
|
|||||||
dfa: 'DIGITAL_FINANCIAL_ASSET',
|
dfa: 'DIGITAL_FINANCIAL_ASSET',
|
||||||
};
|
};
|
||||||
|
|
||||||
type CachedSnapshot = { snapshot: PortfolioDto; storedAt: number };
|
type CachedSnapshot = {
|
||||||
|
generation: string;
|
||||||
|
snapshot: PortfolioDto;
|
||||||
|
storedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PortfolioService {
|
export class PortfolioService {
|
||||||
@@ -43,14 +47,18 @@ export class PortfolioService {
|
|||||||
async get(userId: string, accountId: string): Promise<PortfolioDto> {
|
async get(userId: string, accountId: string): Promise<PortfolioDto> {
|
||||||
this.requestContext.set({ userId, brokerageAccountId: accountId });
|
this.requestContext.set({ userId, brokerageAccountId: accountId });
|
||||||
const key = `${userId}:${accountId}`;
|
const key = `${userId}:${accountId}`;
|
||||||
|
return this.access.withConnection(userId, async ({ token, generation }) => {
|
||||||
const cached = this.snapshots.get(key);
|
const cached = this.snapshots.get(key);
|
||||||
if (cached && Date.now() - cached.storedAt <= freshForMs)
|
if (
|
||||||
|
cached?.generation === generation &&
|
||||||
|
Date.now() - cached.storedAt <= freshForMs
|
||||||
|
)
|
||||||
return cached.snapshot;
|
return cached.snapshot;
|
||||||
try {
|
try {
|
||||||
const snapshot = await this.access.withAccount(
|
const snapshot = await this.access.withAccountForConnection(
|
||||||
userId,
|
{ token, generation },
|
||||||
accountId,
|
accountId,
|
||||||
async ({ token }) => {
|
async () => {
|
||||||
const [portfolio, positions] = await Promise.all([
|
const [portfolio, positions] = await Promise.all([
|
||||||
this.tbank.getPortfolio(token, accountId),
|
this.tbank.getPortfolio(token, accountId),
|
||||||
this.tbank.getPositions(token, accountId),
|
this.tbank.getPositions(token, accountId),
|
||||||
@@ -62,18 +70,23 @@ export class PortfolioService {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
this.snapshots.set(key, { snapshot, storedAt: Date.now() });
|
this.snapshots.set(key, {
|
||||||
|
generation,
|
||||||
|
snapshot,
|
||||||
|
storedAt: Date.now(),
|
||||||
|
});
|
||||||
return snapshot;
|
return snapshot;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (
|
||||||
error instanceof ServiceUnavailableException &&
|
error instanceof ServiceUnavailableException &&
|
||||||
cached &&
|
cached?.generation === generation &&
|
||||||
Date.now() - cached.storedAt <= fallbackForMs
|
Date.now() - cached.storedAt <= fallbackForMs
|
||||||
) {
|
) {
|
||||||
return { ...cached.snapshot, isStale: true };
|
return { ...cached.snapshot, isStale: true };
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidate(userId: string): void {
|
invalidate(userId: string): void {
|
||||||
|
|||||||
@@ -73,6 +73,16 @@ describe('TbankAccessService', () => {
|
|||||||
expect(tbank.getOpenAccounts).not.toHaveBeenCalled();
|
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 () => {
|
it('translates invalid tokens and preserves unavailable upstream errors', async () => {
|
||||||
tbank.getOpenAccounts.mockRejectedValueOnce(
|
tbank.getOpenAccounts.mockRejectedValueOnce(
|
||||||
new Error('INVALID_TBANK_TOKEN'),
|
new Error('INVALID_TBANK_TOKEN'),
|
||||||
@@ -94,6 +104,7 @@ describe('TbankAccessService', () => {
|
|||||||
const handler = jest.fn((context: TbankAccountContext) =>
|
const handler = jest.fn((context: TbankAccountContext) =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
resolvedToken: context.token,
|
resolvedToken: context.token,
|
||||||
|
generation: context.generation,
|
||||||
accountId: context.account.id,
|
accountId: context.account.id,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -102,10 +113,31 @@ describe('TbankAccessService', () => {
|
|||||||
service.withAccount('user', 'account', handler),
|
service.withAccount('user', 'account', handler),
|
||||||
).resolves.toEqual({
|
).resolves.toEqual({
|
||||||
resolvedToken: 'plain-token',
|
resolvedToken: 'plain-token',
|
||||||
|
generation: expect.any(String) as string,
|
||||||
accountId: 'account',
|
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 () => {
|
it('rejects inaccessible accounts before running the handler', async () => {
|
||||||
const handler = jest.fn();
|
const handler = jest.fn();
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { BrokerageAccountDto } from './brokerage-account.dto';
|
import { BrokerageAccountDto } from './brokerage-account.dto';
|
||||||
import {
|
import {
|
||||||
@@ -12,8 +13,12 @@ import {
|
|||||||
import { TbankClient, TbankAccount } from './tbank.client';
|
import { TbankClient, TbankAccount } from './tbank.client';
|
||||||
import { TbankTokenService } from './tbank-token.service';
|
import { TbankTokenService } from './tbank-token.service';
|
||||||
|
|
||||||
export type TbankAccountContext = {
|
export type TbankConnectionContext = {
|
||||||
token: string;
|
token: string;
|
||||||
|
generation: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TbankAccountContext = TbankConnectionContext & {
|
||||||
account: TbankAccount;
|
account: TbankAccount;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -33,13 +38,14 @@ export class TbankAccessService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
handler: (context: {
|
handler: (context: {
|
||||||
token: string;
|
token: string;
|
||||||
|
generation: string;
|
||||||
accounts: BrokerageAccountDto[];
|
accounts: BrokerageAccountDto[];
|
||||||
}) => Promise<T> | T,
|
}) => Promise<T> | T,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const { token } = await this.connection(userId);
|
return this.withConnection(userId, async ({ token, generation }) =>
|
||||||
return this.tokens.translateInvalidToken(async () =>
|
|
||||||
handler({
|
handler({
|
||||||
token,
|
token,
|
||||||
|
generation,
|
||||||
accounts: supportedTbankAccounts(
|
accounts: supportedTbankAccounts(
|
||||||
await this.tbank.getOpenAccounts(token),
|
await this.tbank.getOpenAccounts(token),
|
||||||
),
|
),
|
||||||
@@ -47,28 +53,53 @@ export class TbankAccessService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>(
|
async withAccount<T>(
|
||||||
userId: string,
|
userId: string,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
handler: (context: TbankAccountContext) => Promise<T>,
|
handler: (context: TbankAccountContext) => Promise<T>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const { token } = await this.connection(userId);
|
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 () => {
|
return this.tokens.translateInvalidToken(async () => {
|
||||||
const account = accessibleTbankAccount(
|
const account = accessibleTbankAccount(
|
||||||
await this.tbank.getOpenAccounts(token),
|
await this.tbank.getOpenAccounts(token),
|
||||||
accountId,
|
accountId,
|
||||||
);
|
);
|
||||||
if (!account) throw new NotFoundException();
|
if (!account) throw new NotFoundException();
|
||||||
return handler({ token, account });
|
return handler({ token, generation, account });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async connection(userId: string): Promise<{ token: string }> {
|
private async connection(
|
||||||
|
userId: string,
|
||||||
|
): Promise<{ token: string; generation: string }> {
|
||||||
const connection = await this.prisma.tbankConnection.findUnique({
|
const connection = await this.prisma.tbankConnection.findUnique({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
});
|
});
|
||||||
if (!connection)
|
if (!connection)
|
||||||
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
|
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
|
||||||
return { token: this.tokens.decrypt(connection.encryptedToken) };
|
return {
|
||||||
|
token: this.tokens.decrypt(connection.encryptedToken),
|
||||||
|
generation: createHash('sha256')
|
||||||
|
.update(connection.encryptedToken)
|
||||||
|
.digest('base64url'),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,34 @@ describe('TbankConnectionService', () => {
|
|||||||
expect(portfolio.invalidate).toHaveBeenCalledWith('user-1');
|
expect(portfolio.invalidate).toHaveBeenCalledWith('user-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('re-encrypts the same plaintext token for every successful replacement', async () => {
|
||||||
|
const connectedAt = new Date('2026-08-25T09:00:00.000Z');
|
||||||
|
prisma.tbankConnection.upsert.mockResolvedValue({ connectedAt });
|
||||||
|
tokens.encrypt
|
||||||
|
.mockReturnValueOnce('encrypted:first-nonce')
|
||||||
|
.mockReturnValueOnce('encrypted:second-nonce');
|
||||||
|
|
||||||
|
await service.put('user-1', 'same-token');
|
||||||
|
await service.put('user-1', 'same-token');
|
||||||
|
|
||||||
|
expect(prisma.tbankConnection.upsert).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
expect.objectContaining({
|
||||||
|
update: expect.objectContaining({
|
||||||
|
encryptedToken: 'encrypted:first-nonce',
|
||||||
|
}) as object,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(prisma.tbankConnection.upsert).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
expect.objectContaining({
|
||||||
|
update: expect.objectContaining({
|
||||||
|
encryptedToken: 'encrypted:second-nonce',
|
||||||
|
}) as object,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('deletes a connection idempotently', async () => {
|
it('deletes a connection idempotently', async () => {
|
||||||
await service.delete('user-1');
|
await service.delete('user-1');
|
||||||
|
|
||||||
|
|||||||
@@ -31,15 +31,16 @@ export class TbankConnectionService {
|
|||||||
await this.tokens.translateInvalidToken(() =>
|
await this.tokens.translateInvalidToken(() =>
|
||||||
this.tbank.validateToken(token),
|
this.tbank.validateToken(token),
|
||||||
);
|
);
|
||||||
|
const encryptedToken = this.tokens.encrypt(token);
|
||||||
const connection = await this.prisma.tbankConnection.upsert({
|
const connection = await this.prisma.tbankConnection.upsert({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
create: {
|
create: {
|
||||||
userId,
|
userId,
|
||||||
encryptedToken: this.tokens.encrypt(token),
|
encryptedToken,
|
||||||
connectedAt: new Date(),
|
connectedAt: new Date(),
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
encryptedToken: this.tokens.encrypt(token),
|
encryptedToken,
|
||||||
connectedAt: new Date(),
|
connectedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ describe('TbankCrypto', () => {
|
|||||||
'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=',
|
'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=',
|
||||||
);
|
);
|
||||||
const encrypted = crypto.encrypt('secret-token');
|
const encrypted = crypto.encrypt('secret-token');
|
||||||
|
const replacement = crypto.encrypt('secret-token');
|
||||||
|
|
||||||
expect(encrypted).not.toContain('secret-token');
|
expect(encrypted).not.toContain('secret-token');
|
||||||
|
expect(replacement).not.toBe(encrypted);
|
||||||
expect(crypto.decrypt(encrypted)).toBe('secret-token');
|
expect(crypto.decrypt(encrypted)).toBe('secret-token');
|
||||||
|
expect(crypto.decrypt(replacement)).toBe('secret-token');
|
||||||
expect(() => TbankCrypto.fromBase64('invalid')).toThrow(
|
expect(() => TbankCrypto.fromBase64('invalid')).toThrow(
|
||||||
'Invalid token encryption key',
|
'Invalid token encryption key',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ describe('UpcomingEventsService', () => {
|
|||||||
) =>
|
) =>
|
||||||
handler({
|
handler({
|
||||||
token: 'token',
|
token: 'token',
|
||||||
|
generation: 'generation-1',
|
||||||
account: { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: '' },
|
account: { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: '' },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user