271 lines
10 KiB
TypeScript
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);
|
|
});
|
|
});
|