fix: harden auth and investment synchronization
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
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.
This commit is contained in:
@@ -144,7 +144,12 @@ describe('screen sync HTTP and SQLite', () => {
|
||||
{
|
||||
screen: 'stock',
|
||||
instrumentUid: '00000000-0000-4000-8000-000000000001',
|
||||
candleRange: { from: '2026-02-01', to: '2026-01-01', jobs: [] },
|
||||
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);
|
||||
@@ -161,6 +166,20 @@ describe('screen sync HTTP and SQLite', () => {
|
||||
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();
|
||||
});
|
||||
@@ -287,6 +306,99 @@ describe('screen sync HTTP and SQLite', () => {
|
||||
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())
|
||||
|
||||
Reference in New Issue
Block a user