import type { Server } from 'node:http'; import { INestApplication, NotFoundException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; import request from 'supertest'; import { AppModule } from '../src/app.module'; import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; import { TbankClient } from '../src/integrations/tbank/tbank.client'; import { configureApp } from '../src/main'; import { PrismaService } from '../src/prisma/prisma.service'; describe('Persistent T-Bank connection boundary (SQLite/HTTP)', () => { let app: INestApplication; let prisma: PrismaService; const upstream = { validateToken: jest.fn(), getOpenAccounts: jest.fn() }; const jwt = (owner: string) => new JwtService().sign( { sub: owner, type: 'access' }, { secret: process.env.JWT_SECRET }, ); const put = (token = 'synthetic-token', owner = 'owner') => request(app.getHttpServer()) .put('/api/v1/tbank-connection') .set('Authorization', `Bearer ${jwt(owner)}`) .send({ token }); beforeAll(async () => { const module = await Test.createTestingModule({ imports: [AppModule] }) .overrideProvider(TbankClient) .useValue(upstream) .compile(); app = module.createNestApplication(); configureApp(app); await app.init(); await app.listen(0, '127.0.0.1'); prisma = app.get(PrismaService); for (const id of ['owner', 'other']) { await prisma.user.create({ data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, }); } }); beforeEach(async () => { await prisma.tbankConnection.deleteMany(); jest.clearAllMocks(); upstream.validateToken.mockResolvedValue([ { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: ' Synthetic ' }, { id: 'unsupported', type: 'ACCOUNT_TYPE_UNSPECIFIED', name: 'Ignored' }, ]); }); afterAll(async () => app?.close()); it('publishes validated membership once, checks local ownership without RPC or decrypt, and preserves failed replacement', async () => { await request(app.getHttpServer()) .put('/api/v1/tbank-connection') .send({ token: 'synthetic' }) .expect(401); expect(upstream.validateToken).not.toHaveBeenCalled(); const response = await put(' synthetic-token ').expect(200); expect(Object.keys(response.body as object).sort((a, b) => a.localeCompare(b))).toEqual([ 'connected', 'connectedAt', ]); const saved = await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'owner' }, }); expect(app.get(TbankTokenService).decrypt(saved.encryptedToken)).toBe( 'synthetic-token', ); const decrypt = jest.spyOn(app.get(TbankTokenService), 'decrypt'); const access = app.get(TbankAccessService); const local = await access.local('owner', 'account'); expect( local.accounts.map((account) => [account.accountId, account.name]), ).toEqual([['account', 'Synthetic']]); expect(local.membershipAt).not.toBeNull(); expect(decrypt).not.toHaveBeenCalled(); decrypt.mockRestore(); expect(upstream.validateToken).toHaveBeenCalledTimes(1); expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); await expect(access.local('owner', 'unsupported')).rejects.toBeInstanceOf( NotFoundException, ); await put('other-token', 'other').expect(200); await prisma.tbankAccount.deleteMany({ where: { userId: 'other' } }); await expect(access.local('other', 'account')).rejects.toBeInstanceOf( NotFoundException, ); await prisma.tbankConnection.update({ where: { userId: 'owner' }, data: { membershipAt: new Date(0) }, }); expect((await access.local('owner', 'account')).accounts).toHaveLength(1); upstream.validateToken.mockRejectedValueOnce( new Error('INVALID_TBANK_TOKEN'), ); expect((await put('invalid').expect(400)).body).toMatchObject({ code: 'INVALID_TBANK_TOKEN', }); expect( ( await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'owner' }, }) ).revision, ).toBe(saved.revision); expect((await access.local('owner', 'account')).accounts).toHaveLength(1); }); it('rotates even the same token atomically, removes private rows and fences late writes after replace/delete', async () => { await put().expect(200); const old = await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'owner' }, }); const operation = { userId: 'owner', revision: old.revision, accountId: 'account', brokerOperationId: 'operation', occurredAt: new Date(), category: 'income', payload: { payment: { value: '1.000000001', currency: 'RUB' } }, }; await prisma.tbankOperation.create({ data: operation }); upstream.validateToken.mockResolvedValueOnce([ { id: 'duplicate', type: 'ACCOUNT_TYPE_TINKOFF', name: 'First' }, { id: 'duplicate', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Second' }, ]); await put('candidate-with-invalid-membership').expect(500); expect( ( await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'owner' }, }) ).revision, ).toBe(old.revision); expect(await prisma.tbankOperation.count()).toBe(1); await put().expect(200); const replacement = await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'owner' }, }); expect(replacement.revision).not.toBe(old.revision); expect(replacement.encryptedToken).not.toBe(old.encryptedToken); expect(await prisma.tbankOperation.count()).toBe(0); await expect( prisma.tbankOperation.create({ data: operation }), ).rejects.toMatchObject({ code: 'P2003' }); for (let index = 0; index < 2; index += 1) { await request(app.getHttpServer()) .delete('/api/v1/tbank-connection') .set('Authorization', `Bearer ${jwt('owner')}`) .expect(204); } await expect( prisma.tbankOperation.create({ data: { ...operation, revision: replacement.revision }, }), ).rejects.toMatchObject({ code: 'P2003' }); await expect( app.get(TbankAccessService).local('owner'), ).rejects.toMatchObject({ response: { code: 'TBANK_NOT_CONNECTED' } }); }); it('distinguishes unknown membership, complete empty membership and known invalid credentials', async () => { await prisma.tbankConnection.create({ data: { userId: 'owner', encryptedToken: 'never-decrypted', connectedAt: new Date(), }, }); const access = app.get(TbankAccessService); expect((await access.local('owner', 'unknown')).membershipAt).toBeNull(); upstream.validateToken.mockResolvedValueOnce([]); await put().expect(200); expect((await access.local('owner')).accounts).toEqual([]); await expect(access.local('owner', 'unknown')).rejects.toBeInstanceOf( NotFoundException, ); await prisma.tbankConnection.update({ where: { userId: 'owner' }, data: { invalidToken: true }, }); await expect(access.local('owner')).rejects.toMatchObject({ response: { code: 'INVALID_TBANK_TOKEN' }, }); await request(app.getHttpServer()) .get('/api/v1/brokerage-accounts') .set('Authorization', `Bearer ${jwt('owner')}`) .expect(400) .expect((response) => { expect(response.body).toMatchObject({ code: 'INVALID_TBANK_TOKEN' }); }); expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); }); it('publishes membership atomically without losing retained data and rejects superseded attempts', async () => { await put().expect(200); const access = app.get(TbankAccessService); const connection = await access.local('owner'); const scope = { userId: 'owner', revision: connection.revision }; const attempt = { ...scope, id: 'membership', attemptId: 'first' }; await prisma.tbankSyncWork.create({ data: { ...attempt, scopeKey: 'membership', state: 'running', intent: {}, progress: {}, processRunId: 'test', priority: 0, }, }); await prisma.tbankPortfolio.create({ data: { ...scope, accountId: 'account', payload: { total: '123.000000001' }, fetchedAt: new Date(0), }, }); const accounts = [ { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Synthetic' }, ]; expect( await access.publishMembership(attempt, accounts, new Date(1000)), ).toBe(true); expect((await access.local('owner')).membershipRevision).toBe( connection.membershipRevision, ); expect(await prisma.tbankPortfolio.count()).toBe(1); await prisma.tbankSyncWork.update({ where: { id: attempt.id }, data: { attemptId: 'second' }, }); expect(await access.publishMembership(attempt, [], new Date(2000))).toBe( false, ); expect(await access.rejectMembershipToken(attempt)).toBe(false); expect((await access.local('owner')).membershipAt).toEqual(new Date(1000)); const current = { ...attempt, attemptId: 'second' }; await expect( access.publishMembership( current, [...accounts, ...accounts], new Date(3000), ), ).rejects.toThrow(); expect(await prisma.tbankPortfolio.count()).toBe(1); expect(await access.publishMembership(current, [], new Date(4000))).toBe( true, ); expect(await prisma.tbankPortfolio.count()).toBe(0); const empty = await access.local('owner'); expect(empty.accounts).toEqual([]); expect(empty.membershipRevision).toBe(connection.membershipRevision + 1); await put('replacement').expect(200); expect(await access.publishMembership(current, [], new Date(5000))).toBe( false, ); expect(await access.rejectMembershipToken(current)).toBe(false); expect((await access.local('owner')).accounts).toHaveLength(1); }); });