fix(tbank): isolate caches by connection generation

This commit is contained in:
2026-09-01 05:55:01 +03:00
parent da17893350
commit 19fe7e75d5
10 changed files with 317 additions and 59 deletions
@@ -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',
};
}
+10 -5
View File
@@ -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(),
+99 -15
View File
@@ -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' },
]);
});
}); });
+43 -30
View File
@@ -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,37 +47,46 @@ 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}`;
const cached = this.snapshots.get(key); return this.access.withConnection(userId, async ({ token, generation }) => {
if (cached && Date.now() - cached.storedAt <= freshForMs) const cached = this.snapshots.get(key);
return cached.snapshot;
try {
const snapshot = await this.access.withAccount(
userId,
accountId,
async ({ token }) => {
const [portfolio, positions] = await Promise.all([
this.tbank.getPortfolio(token, accountId),
this.tbank.getPositions(token, accountId),
]);
return this.normalize(
token,
portfolio.positions ?? [],
positions.money ?? [],
);
},
);
this.snapshots.set(key, { snapshot, storedAt: Date.now() });
return snapshot;
} catch (error) {
if ( if (
error instanceof ServiceUnavailableException && cached?.generation === generation &&
cached && Date.now() - cached.storedAt <= freshForMs
Date.now() - cached.storedAt <= fallbackForMs )
) { return cached.snapshot;
return { ...cached.snapshot, isStale: true }; try {
const snapshot = await this.access.withAccountForConnection(
{ token, generation },
accountId,
async () => {
const [portfolio, positions] = await Promise.all([
this.tbank.getPortfolio(token, accountId),
this.tbank.getPositions(token, accountId),
]);
return this.normalize(
token,
portfolio.positions ?? [],
positions.money ?? [],
);
},
);
this.snapshots.set(key, {
generation,
snapshot,
storedAt: Date.now(),
});
return snapshot;
} catch (error) {
if (
error instanceof ServiceUnavailableException &&
cached?.generation === generation &&
Date.now() - cached.storedAt <= fallbackForMs
) {
return { ...cached.snapshot, isStale: true };
}
throw error;
} }
throw error; });
}
} }
invalidate(userId: string): void { invalidate(userId: string): void {
+32
View File
@@ -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();
+38 -7
View File
@@ -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');
+3 -2
View File
@@ -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(),
}, },
}); });
+3
View File
@@ -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: '' },
}), }),
); );