diff --git a/package-lock.json b/package-lock.json index 0c7fad7..e3d32ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "cookie-parser": "^1.4.7", + "lru-cache": "^11.2.4", "nestjs-pino": "^4.6.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", @@ -60,6 +61,9 @@ "tsconfig-paths": "^4.2.0", "typescript": "^5.7.3", "typescript-eslint": "^8.20.0" + }, + "engines": { + "node": "22.x" } }, "node_modules/@angular-devkit/core": { @@ -306,6 +310,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, "node_modules/@babel/helper-compilation-targets/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -8707,13 +8721,12 @@ "license": "Apache-2.0" }, "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" } }, "node_modules/lru.min": { diff --git a/package.json b/package.json index a40108a..829434a 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "cookie-parser": "^1.4.7", + "lru-cache": "^11.2.4", "nestjs-pino": "^4.6.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts new file mode 100644 index 0000000..78400ba --- /dev/null +++ b/src/auth/auth.service.spec.ts @@ -0,0 +1,150 @@ +import { ConflictException, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { createHash } from 'crypto'; + +jest.mock('bcrypt', () => ({ + hash: jest.fn(() => Promise.resolve('password-hash')), + compare: jest.fn(() => Promise.resolve(true)), +})); + +jest.mock('../prisma/prisma.service', () => ({ + PrismaService: class PrismaService {}, +})); + +import { AuthService } from './auth.service'; + +const user = { + id: 'user', + email: 'user@example.com', + passwordHash: 'password-hash', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +}; + +function hash(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + +function twoCallBarrier(value: T): () => Promise { + let calls = 0; + let release: () => void; + const ready = new Promise((resolve) => { + release = resolve; + }); + return async () => { + calls += 1; + if (calls === 2) release(); + await ready; + return value; + }; +} + +describe('AuthService', () => { + const jwt = new JwtService({ secret: 'test-secret-that-is-long-enough' }); + const prisma = { + user: { findUnique: jest.fn(), create: jest.fn() }, + session: { + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + updateMany: jest.fn(), + delete: jest.fn(), + }, + }; + const service = new AuthService(prisma as never, jwt); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('maps the database loser of a concurrent registration to EMAIL_ALREADY_REGISTERED', async () => { + prisma.user.findUnique.mockImplementation(twoCallBarrier(null)); + prisma.user.create + .mockResolvedValueOnce(user) + .mockRejectedValueOnce( + Object.assign(new Error('duplicate email'), { code: 'P2002' }), + ); + prisma.session.create.mockResolvedValue({ + id: 'session', + userId: user.id, + refreshTokenHash: '', + expiresAt: new Date(), + revokedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + prisma.session.update.mockResolvedValue({}); + + const results = await Promise.allSettled([ + service.register(user.email, 'secure password'), + service.register(user.email, 'secure password'), + ]); + + const rejected = results.find( + (result): result is PromiseRejectedResult => result.status === 'rejected', + ); + expect( + results.filter((result) => result.status === 'fulfilled'), + ).toHaveLength(1); + expect(rejected?.reason).toEqual(expect.any(ConflictException)); + expect((rejected?.reason as ConflictException).getResponse()).toEqual({ + code: 'EMAIL_ALREADY_REGISTERED', + }); + }); + + it('allows one concurrent refresh winner without revoking its session', async () => { + const oldToken = await jwt.signAsync( + { sub: user.id, sid: 'session', type: 'refresh', jti: 'old-token' }, + { expiresIn: '7d', algorithm: 'HS256' }, + ); + let currentHash = hash(oldToken); + const session = { + id: 'session', + userId: user.id, + refreshTokenHash: currentHash, + expiresAt: new Date(Date.now() + 60_000), + revokedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + user, + }; + prisma.session.findUnique.mockImplementation(twoCallBarrier(session)); + prisma.session.updateMany.mockImplementation( + ({ + where, + data, + }: { + where: { id?: string; refreshTokenHash?: string }; + data: { refreshTokenHash: string }; + }) => { + if (where.id === session.id && where.refreshTokenHash === currentHash) { + currentHash = data.refreshTokenHash; + return Promise.resolve({ count: 1 }); + } + return Promise.resolve({ count: 0 }); + }, + ); + + const results = await Promise.allSettled([ + service.refresh(oldToken), + service.refresh(oldToken), + ]); + + const rejected = results.find( + (result): result is PromiseRejectedResult => result.status === 'rejected', + ); + expect( + results.filter((result) => result.status === 'fulfilled'), + ).toHaveLength(1); + expect(rejected?.reason).toEqual(expect.any(UnauthorizedException)); + expect(prisma.session.updateMany).toHaveBeenCalledTimes(2); + expect( + prisma.session.updateMany.mock.calls.map( + ([input]) => + (input as { where: { id?: string; refreshTokenHash?: string } }) + .where, + ), + ).toContainEqual({ id: session.id, refreshTokenHash: hash(oldToken) }); + expect(prisma.session.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index be633c7..9c66a5c 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -33,13 +33,20 @@ export class AuthService { if (existing) throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' }); - const user = await this.prisma.user.create({ - data: { - id: randomUUID(), - email: normalizedEmail, - passwordHash: await bcrypt.hash(password, 12), - }, - }); + let user: User; + try { + user = await this.prisma.user.create({ + data: { + id: randomUUID(), + email: normalizedEmail, + passwordHash: await bcrypt.hash(password, 12), + }, + }); + } catch (error) { + if (isUniqueConstraintError(error)) + throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' }); + throw error; + } return this.createSession(user); } @@ -86,14 +93,9 @@ export class AuthService { await this.prisma.session.delete({ where: { id: session.id } }); throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); } - if (session.refreshTokenHash !== this.hash(token)) { - await this.prisma.session.update({ - where: { id: session.id }, - data: { revokedAt: new Date() }, - }); + if (session.refreshTokenHash !== this.hash(token)) throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); - } - return this.rotateSession(session); + return this.rotateSession(session, session.refreshTokenHash); } async logout(token?: string): Promise { @@ -147,6 +149,7 @@ export class AuthService { private async rotateSession( session: Session & { user: User }, + expectedRefreshTokenHash?: string, ): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { const refreshToken = await this.jwt.signAsync( { @@ -157,13 +160,20 @@ export class AuthService { }, { expiresIn: REFRESH_TTL, algorithm: 'HS256' }, ); - await this.prisma.session.update({ - where: { id: session.id }, - data: { - refreshTokenHash: this.hash(refreshToken), - expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), - }, - }); + const data = { + refreshTokenHash: this.hash(refreshToken), + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }; + if (expectedRefreshTokenHash) { + const updated = await this.prisma.session.updateMany({ + where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash }, + data, + }); + if (updated.count !== 1) + throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + } else { + await this.prisma.session.update({ where: { id: session.id }, data }); + } return { user: this.toUser(session.user), accessToken: await this.jwt.signAsync( @@ -182,3 +192,12 @@ export class AuthService { return createHash('sha256').update(token).digest('hex'); } } + +function isUniqueConstraintError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: unknown }).code === 'P2002' + ); +} diff --git a/src/tbank/brokerage-operations.service.spec.ts b/src/tbank/brokerage-operations.service.spec.ts index deec402..bbabaad 100644 --- a/src/tbank/brokerage-operations.service.spec.ts +++ b/src/tbank/brokerage-operations.service.spec.ts @@ -31,6 +31,8 @@ describe('BrokerageOperationsService', () => { beforeEach(() => { jest.clearAllMocks(); + tbank.getOpenAccounts.mockReset(); + tbank.getOperationsByCursor.mockReset(); prisma.tbankConnection.findUnique.mockResolvedValue({ encryptedToken: crypto.encrypt('token'), }); @@ -46,6 +48,11 @@ describe('BrokerageOperationsService', () => { (service as unknown as { pages: Map }).pages.clear(); }); + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + it('uses the current UTC year by default and asks only for executed operations', async () => { await service.get('user', 'account', {}); @@ -134,4 +141,41 @@ describe('BrokerageOperationsService', () => { expect.any(ServiceUnavailableException), ); }); + + it('reuses one fresh page for equivalent categories within the default end-time bucket', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-08-23T12:00:00.100Z')); + + await service.get('user', 'account', { + categories: ['tax', 'income', 'income'], + }); + jest.advanceTimersByTime(500); + await service.get('user', 'account', { categories: ['income', 'tax'] }); + + expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(1); + }); + + it('does not let a cached page bypass account ownership checks', async () => { + const query = { from: '2025-01-01', to: '2025-01-31' }; + await service.get('user', 'account', query); + tbank.getOpenAccounts.mockResolvedValueOnce([]); + + await expect(service.get('user', 'account', query)).rejects.toEqual( + expect.any(NotFoundException), + ); + expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(1); + }); + + it('evicts the least recently used ledger page after one hundred entries', async () => { + for (let index = 0; index < 100; index += 1) + await service.get('user', 'account', { cursor: String(index) }); + + await service.get('user', 'account', { cursor: '0' }); + await service.get('user', 'account', { cursor: '100' }); + await service.get('user', 'account', { cursor: '1' }); + + const pages = (service as unknown as { pages: { size: number } }).pages; + expect(pages.size).toBe(100); + expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(102); + }); }); diff --git a/src/tbank/brokerage-operations.service.ts b/src/tbank/brokerage-operations.service.ts index be85b87..184f68f 100644 --- a/src/tbank/brokerage-operations.service.ts +++ b/src/tbank/brokerage-operations.service.ts @@ -5,6 +5,7 @@ import { NotFoundException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { LRUCache } from 'lru-cache'; import { RequestContextService } from '../common/logging/request-context.service'; import { Environment } from '../config/env.validation'; import { PrismaService } from '../prisma/prisma.service'; @@ -20,6 +21,7 @@ import { TbankClient } from './tbank.client'; import { TbankCrypto } from './tbank.crypto'; const freshForMs = 30_000; +const maxCachedPages = 100; const supportedAccounts = new Set([ 'ACCOUNT_TYPE_TINKOFF', 'ACCOUNT_TYPE_TINKOFF_IIS', @@ -30,10 +32,11 @@ const supportedAccounts = new Set([ @Injectable() export class BrokerageOperationsService { private readonly crypto: TbankCrypto; - private readonly pages = new Map< - string, - { page: BrokerageOperationsPageDto; storedAt: number } - >(); + private readonly pages = new LRUCache({ + max: maxCachedPages, + ttl: freshForMs, + ttlAutopurge: true, + }); constructor( private readonly prisma: PrismaService, @@ -53,10 +56,6 @@ export class BrokerageOperationsService { ): Promise { this.requestContext.set({ userId, brokerageAccountId: accountId }); const range = this.range(query); - const key = JSON.stringify({ userId, accountId, ...query, ...range }); - const cached = this.pages.get(key); - if (cached && Date.now() - cached.storedAt <= freshForMs) - return cached.page; try { const connection = await this.prisma.tbankConnection.findUnique({ @@ -74,9 +73,13 @@ export class BrokerageOperationsService { ) throw new NotFoundException(); + const key = cacheKey(userId, accountId, query, range); + const cached = this.pages.get(key); + if (cached) return cached; + if (range.from > range.to) { const page = emptyPage(accountId); - this.pages.set(key, { page, storedAt: Date.now() }); + this.pages.set(key, page); return page; } @@ -101,7 +104,7 @@ export class BrokerageOperationsService { hasNext: Boolean(response.hasNext), observedAt: new Date().toISOString(), }; - this.pages.set(key, { page, storedAt: Date.now() }); + this.pages.set(key, page); return page; } catch (error) { if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') @@ -121,6 +124,27 @@ export class BrokerageOperationsService { } } +function cacheKey( + userId: string, + accountId: string, + query: BrokerageOperationsQueryDto, + range: { from: Date; to: Date }, +): string { + const categories = [...new Set(query.categories ?? [])].sort(); + const end = query.to + ? range.to.toISOString() + : Math.floor(range.to.valueOf() / freshForMs); + return JSON.stringify({ + userId, + accountId, + from: range.from.toISOString(), + to: end, + categories, + cursor: query.cursor ?? null, + limit: query.limit ?? 100, + }); +} + function dateBoundary(value: string | undefined, end: boolean): Date | null { if (!value) return null; if (/^\d{4}-\d{2}-\d{2}$/.test(value))