Files
backend/test/tbank-connection.e2e-spec.ts
T
ksv741 434006f181
Backend verify / commits (pull_request) Successful in 31s
Backend verify / security (pull_request) Successful in 11s
Backend verify / verify (pull_request) Successful in 5m24s
test: isolate HTTP suites from scheduler and bank-card case order (WealthLedger/backend#65)
2026-10-15 09:02:10 +03:00

271 lines
10 KiB
TypeScript

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 { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler';
import { PrismaService } from '../src/prisma/prisma.service';
describe('Persistent T-Bank connection boundary (SQLite/HTTP)', () => {
let app: INestApplication<Server>;
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)
.overrideProvider(TbankHourlyScheduler)
.useValue({ tick: jest.fn() })
.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);
});
});