Backend security / security (push) Successful in 8s
Backend verify / verify (push) Successful in 11m9s
Conventional commits / commits (pull_request) Successful in 28s
Backend security / security (pull_request) Successful in 7s
Backend verify / verify (pull_request) Successful in 10m38s
Resolve A01-A10 and C01-C07 from the backend audit. Preserve auth failures, forced candle reconciliation and forecast freshness. Validate MOEX responses and nested HTTP errors; remove unused legacy paths. Verified: 209 unit tests, 262 SQLite e2e tests on Linux, OpenAPI and durability. Record the remaining macOS native SQLite abort in the audit.
411 lines
14 KiB
TypeScript
411 lines
14 KiB
TypeScript
import { INestApplication } from '@nestjs/common';
|
|
import { JwtService } from '@nestjs/jwt';
|
|
import { Test } from '@nestjs/testing';
|
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
import type { Server } from 'node:http';
|
|
import request from 'supertest';
|
|
import { AppModule } from '../src/app.module';
|
|
import { configureApp } from '../src/main';
|
|
import { PrismaService } from '../src/prisma/prisma.service';
|
|
import { TbankClient } from '../src/integrations/tbank/tbank.client';
|
|
import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service';
|
|
import { TbankSyncStatusDto } from '../src/modules/investments/tbank-sync/tbank-sync.dto';
|
|
|
|
describe('screen sync HTTP and SQLite', () => {
|
|
let app: INestApplication<Server>;
|
|
let prisma: PrismaService;
|
|
const upstream = {
|
|
getOpenAccounts: jest.fn(),
|
|
getPortfolio: jest.fn(),
|
|
getPositions: jest.fn(),
|
|
getOperationsByCursor: jest.fn(),
|
|
findInstrument: jest.fn(),
|
|
};
|
|
const auth = (id = 'owner') =>
|
|
`Bearer ${new JwtService().sign({ sub: id, type: 'access' }, { secret: process.env.JWT_SECRET })}`;
|
|
const post = (scope: object, id = 'owner') =>
|
|
request(app.getHttpServer())
|
|
.post('/api/v1/tbank-sync')
|
|
.set('Authorization', auth(id))
|
|
.send(scope);
|
|
const status = (id: string, owner = 'owner') =>
|
|
request(app.getHttpServer())
|
|
.get(`/api/v1/tbank-sync/${id}`)
|
|
.set('Authorization', auth(owner));
|
|
async function settled(id: string) {
|
|
for (let i = 0; i < 300; i++) {
|
|
const response = await status(id).expect(200);
|
|
const body = response.body as TbankSyncStatusDto;
|
|
if (body.state !== 'running') return body;
|
|
await delay(5);
|
|
}
|
|
throw new Error('Screen work did not settle');
|
|
}
|
|
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.resetAllMocks();
|
|
upstream.getOpenAccounts.mockResolvedValue([]);
|
|
upstream.getPortfolio.mockResolvedValue({ positions: [] });
|
|
upstream.getPositions.mockResolvedValue({ money: [] });
|
|
for (const userId of ['owner', 'other'])
|
|
await prisma.tbankConnection.create({
|
|
data: {
|
|
userId,
|
|
encryptedToken: app.get(TbankTokenService).encrypt('synthetic'),
|
|
connectedAt: new Date(),
|
|
membershipAt: new Date(),
|
|
membershipRevision: 1,
|
|
},
|
|
});
|
|
});
|
|
afterAll(async () => app.close());
|
|
it('accepts before blocked RPC finishes, joins identical requests, and observes without starting work', async () => {
|
|
let release: (value: unknown[]) => void = () => {};
|
|
upstream.getOpenAccounts.mockReturnValue(
|
|
new Promise((resolve) => {
|
|
release = resolve;
|
|
}),
|
|
);
|
|
const responses = await Promise.all(
|
|
Array.from({ length: 20 }, () =>
|
|
post({ screen: 'accounts' }).expect(202),
|
|
),
|
|
);
|
|
const ids = responses.map(
|
|
(response) => (response.body as { id: string }).id,
|
|
);
|
|
expect(new Set(ids).size).toBe(1);
|
|
expect((await status(ids[0]).expect(200)).body).toMatchObject({
|
|
state: 'running',
|
|
scope: { screen: 'accounts' },
|
|
});
|
|
await status(ids[0], 'other').expect(404);
|
|
expect(upstream.getOpenAccounts).toHaveBeenCalledTimes(1);
|
|
release([]);
|
|
const done = await settled(ids[0]);
|
|
expect(done.state).toBe('succeeded');
|
|
expect(done.resources[0]).toMatchObject({
|
|
resourceKey: 'accounts',
|
|
availability: 'complete',
|
|
});
|
|
expect(JSON.stringify(done)).not.toMatch(
|
|
/revision|attemptId|encryptedToken|synthetic/,
|
|
);
|
|
await status(ids[0]).expect(200);
|
|
expect(upstream.getOpenAccounts).toHaveBeenCalledTimes(1);
|
|
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
|
});
|
|
it.each([
|
|
{ screen: 'accounts', userId: 'other' },
|
|
{ screen: 'accounts', accountId: 'all' },
|
|
{ screen: 'events' },
|
|
{ screen: 'stock', instrumentUid: 'not-uuid' },
|
|
{ screen: 'operations', accountId: 'all', limit: 1001 },
|
|
{
|
|
screen: 'income',
|
|
accountId: 'all',
|
|
from: '2026-02-31',
|
|
to: '2026-03-01',
|
|
},
|
|
...(['income', 'events'] as const).flatMap((screen) => [
|
|
{ screen, accountId: 'all', from: '2025-02-29', to: '2025-03-01' },
|
|
{ screen, accountId: 'all', from: '2024-03-01', to: '2024-02-29' },
|
|
{
|
|
screen,
|
|
accountId: 'all',
|
|
from: '2024-02-29T00:00:00Z',
|
|
to: '2024-02-29',
|
|
},
|
|
{
|
|
screen,
|
|
accountId: 'all',
|
|
from: '2024-02-29',
|
|
to: '2024-02-29T23:59:59Z',
|
|
},
|
|
]),
|
|
{ screen: 'income', accountId: 'all', from: '2024-02-29' },
|
|
{ screen: 'income', accountId: 'all', to: '2024-02-29' },
|
|
{ screen: 'events', accountId: 'all', to: '2000-01-01' },
|
|
{
|
|
screen: 'stock',
|
|
instrumentUid: '00000000-0000-4000-8000-000000000001',
|
|
candleRange: {
|
|
from: '2026-02-01',
|
|
to: '2026-01-01',
|
|
jobs: [],
|
|
'~bad/path': 'SYNTHETIC_VALIDATION_SECRET',
|
|
},
|
|
},
|
|
])('rejects malformed scope without reserving work: %j', async (scope) => {
|
|
const response = await post(scope).expect(400);
|
|
expect(response.headers['content-type']).toContain(
|
|
'application/problem+json',
|
|
);
|
|
const problem = response.body as {
|
|
code: string;
|
|
errors: { pointer: string; detail: string }[];
|
|
};
|
|
expect(problem.code).toBe('VALIDATION_ERROR');
|
|
expect(problem.errors.length).toBeGreaterThan(0);
|
|
for (const error of problem.errors) {
|
|
expect(typeof error.pointer).toBe('string');
|
|
expect(typeof error.detail).toBe('string');
|
|
}
|
|
if ('candleRange' in scope) {
|
|
expect(
|
|
problem.errors.filter(
|
|
(error) => error.pointer === '#/candleRange/jobs',
|
|
),
|
|
).toHaveLength(1);
|
|
expect(problem.errors).toContainEqual({
|
|
pointer: '#/candleRange/~0bad~1path',
|
|
detail: expect.any(String) as unknown,
|
|
});
|
|
expect(JSON.stringify(response.body)).not.toContain(
|
|
'SYNTHETIC_VALIDATION_SECRET',
|
|
);
|
|
}
|
|
expect(await prisma.tbankSyncWork.count()).toBe(0);
|
|
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
|
});
|
|
it.each([
|
|
{
|
|
screen: 'income',
|
|
accountId: 'all',
|
|
from: '2024-02-29',
|
|
to: '2024-02-29',
|
|
},
|
|
{
|
|
screen: 'events',
|
|
accountId: 'all',
|
|
from: '2096-02-29',
|
|
to: '2096-02-29',
|
|
},
|
|
{ screen: 'events', accountId: 'all', from: '2096-02-29' },
|
|
{ screen: 'events', accountId: 'all', to: '2096-02-29' },
|
|
{
|
|
screen: 'operations',
|
|
accountId: 'all',
|
|
from: '2024-02-29T00:00:00Z',
|
|
to: '2024-03-01T00:00:00Z',
|
|
},
|
|
])(
|
|
'preserves valid calendar/instant scope in accepted and status responses: %j',
|
|
async (scope) => {
|
|
const response = await post(scope).expect(202);
|
|
const accepted = response.body as { id: string; scope: object };
|
|
expect(accepted.scope).toEqual(scope);
|
|
const result = await settled(accepted.id);
|
|
expect(result.scope).toEqual(scope);
|
|
expect(result.state).toBe('succeeded');
|
|
},
|
|
);
|
|
it('keeps failed work terminal, cancels abandoned work without restart, and hides old connection handles', async () => {
|
|
upstream.getOpenAccounts.mockRejectedValue(
|
|
new Error('upstream details never exposed'),
|
|
);
|
|
const accepted = await post({ screen: 'accounts' }).expect(202);
|
|
const id = (accepted.body as { id: string }).id;
|
|
const failed = await settled(id);
|
|
expect(failed.state).toBe('failed');
|
|
expect(failed.errorCode).toBe('TBANK_UNAVAILABLE');
|
|
await status(id).expect(200);
|
|
expect(upstream.getOpenAccounts).toHaveBeenCalledTimes(1);
|
|
await prisma.tbankSyncWork.update({
|
|
where: { id },
|
|
data: {
|
|
state: 'running',
|
|
errorCode: null,
|
|
processRunId: 'previous-process',
|
|
progress: { children: [], dispatched: false },
|
|
},
|
|
});
|
|
expect((await status(id).expect(200)).body).toMatchObject({
|
|
state: 'cancelled',
|
|
});
|
|
expect(upstream.getOpenAccounts).toHaveBeenCalledTimes(1);
|
|
await prisma.tbankConnection.delete({ where: { userId: 'owner' } });
|
|
await status(id).expect(404);
|
|
});
|
|
it('refreshes only the applied operation period and reports empty coverage changes without changing data revision', async () => {
|
|
const account = {
|
|
id: 'a',
|
|
name: 'Synthetic',
|
|
type: 'ACCOUNT_TYPE_TINKOFF',
|
|
openedDate: { seconds: '1577836800' },
|
|
};
|
|
upstream.getOpenAccounts.mockResolvedValue([account]);
|
|
const connection = await prisma.tbankConnection.findUniqueOrThrow({
|
|
where: { userId: 'owner' },
|
|
});
|
|
await prisma.tbankAccount.create({
|
|
data: {
|
|
userId: 'owner',
|
|
revision: connection.revision,
|
|
accountId: 'a',
|
|
name: 'Synthetic',
|
|
type: 'ACCOUNT_TYPE_TINKOFF',
|
|
openedOn: '2020-01-01',
|
|
upstreamOrder: 0,
|
|
},
|
|
});
|
|
const path =
|
|
'/api/v1/brokerage-accounts/a/operations?from=2025-01-01&to=2025-01-31&sync=observe';
|
|
const before = await request(app.getHttpServer())
|
|
.get(path)
|
|
.set('Authorization', auth())
|
|
.expect(200);
|
|
upstream.getOperationsByCursor.mockResolvedValue({
|
|
items: [],
|
|
hasNext: false,
|
|
});
|
|
const response = await post({
|
|
screen: 'operations',
|
|
accountId: 'a',
|
|
from: '2025-01-01',
|
|
to: '2025-01-31',
|
|
}).expect(202);
|
|
const done = await settled((response.body as { id: string }).id);
|
|
expect(done.state).toBe('succeeded');
|
|
expect(upstream.getPortfolio).not.toHaveBeenCalled();
|
|
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1);
|
|
const after = await request(app.getHttpServer())
|
|
.get(path)
|
|
.set('Authorization', auth())
|
|
.expect(200);
|
|
const first = before.body as {
|
|
meta: { dataRevision: string; stateVersion: string };
|
|
};
|
|
const last = after.body as {
|
|
data: { items: unknown[] };
|
|
meta: {
|
|
dataRevision: string;
|
|
stateVersion: string;
|
|
sync: { id: string };
|
|
};
|
|
};
|
|
expect(last.data.items).toEqual([]);
|
|
expect(last.meta.dataRevision).toBe(first.meta.dataRevision);
|
|
expect(last.meta.stateVersion).not.toBe(first.meta.stateVersion);
|
|
expect(done.resources[0].stateVersion).toBe(last.meta.stateVersion);
|
|
await status(last.meta.sync.id).expect(200);
|
|
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1);
|
|
});
|
|
it.each(['succeeded', 'failed'] as const)(
|
|
'observes the operations successor until it is %s',
|
|
async (outcome) => {
|
|
const accounts = [
|
|
{
|
|
id: 'a',
|
|
name: 'Synthetic',
|
|
type: 'ACCOUNT_TYPE_TINKOFF',
|
|
openedDate: { seconds: '1577836800' },
|
|
},
|
|
];
|
|
let releaseMembership: (value: typeof accounts) => void = () => {};
|
|
upstream.getOpenAccounts.mockReturnValue(
|
|
new Promise((resolve) => {
|
|
releaseMembership = resolve;
|
|
}),
|
|
);
|
|
const connection = await prisma.tbankConnection.findUniqueOrThrow({
|
|
where: { userId: 'owner' },
|
|
});
|
|
await prisma.tbankAccount.create({
|
|
data: {
|
|
userId: 'owner',
|
|
revision: connection.revision,
|
|
accountId: 'a',
|
|
name: 'Synthetic',
|
|
type: 'ACCOUNT_TYPE_TINKOFF',
|
|
openedOn: '2020-01-01',
|
|
upstreamOrder: 0,
|
|
},
|
|
});
|
|
let release: (value: {
|
|
hasNext: boolean;
|
|
items: never[];
|
|
}) => void = () => {};
|
|
let reject: (error: Error) => void = () => {};
|
|
upstream.getOperationsByCursor
|
|
.mockReturnValueOnce(
|
|
new Promise((resolve, rejectResult) => {
|
|
release = resolve;
|
|
reject = rejectResult;
|
|
}),
|
|
)
|
|
.mockImplementation(() =>
|
|
outcome === 'failed'
|
|
? Promise.reject(new Error('Synthetic outage'))
|
|
: Promise.resolve({ hasNext: false, items: [] }),
|
|
);
|
|
const accept = async (from: string, to: string) => {
|
|
const response = await post({
|
|
screen: 'operations',
|
|
accountId: 'a',
|
|
from,
|
|
to,
|
|
}).expect(202);
|
|
return (response.body as { id: string }).id;
|
|
};
|
|
const dispatched = async (id: string) => {
|
|
for (let i = 0; i < 300; i++) {
|
|
const work = await prisma.tbankSyncWork.findUniqueOrThrow({
|
|
where: { id },
|
|
});
|
|
if ((work.progress as { dispatched: boolean }).dispatched) return id;
|
|
await delay(5);
|
|
}
|
|
throw new Error('Screen work did not dispatch');
|
|
};
|
|
const first = await accept('2026-01-01', '2026-01-10');
|
|
const second = await accept('2026-01-20', '2026-01-31');
|
|
const merged = await accept('2026-01-01', '2026-01-31');
|
|
releaseMembership(accounts);
|
|
await Promise.all([first, second, merged].map(dispatched));
|
|
try {
|
|
expect((await status(second).expect(200)).body).toMatchObject({
|
|
state: 'running',
|
|
progress: { completed: 1, total: 2, discovering: false },
|
|
});
|
|
await status(second, 'other').expect(404);
|
|
} finally {
|
|
if (outcome === 'failed') reject(new Error('Synthetic outage'));
|
|
else release({ hasNext: false, items: [] });
|
|
await settled(merged);
|
|
await settled(first);
|
|
}
|
|
expect(await settled(second)).toMatchObject({
|
|
state: outcome,
|
|
errorCode: outcome === 'failed' ? 'TBANK_UNAVAILABLE' : null,
|
|
progress: { completed: 2, total: 2, discovering: false },
|
|
});
|
|
await prisma.tbankConnection.delete({ where: { userId: 'owner' } });
|
|
await status(second).expect(404);
|
|
},
|
|
);
|
|
it('rejects known foreign accounts locally and requires authentication', async () => {
|
|
await post({ screen: 'operations', accountId: 'foreign' }).expect(404);
|
|
await request(app.getHttpServer())
|
|
.post('/api/v1/tbank-sync')
|
|
.send({ screen: 'accounts' })
|
|
.expect(401);
|
|
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
|
});
|
|
});
|