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

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:
2026-09-27 14:09:05 +03:00
parent 5d3835e796
commit 4796d9d3f0
55 changed files with 1954 additions and 569 deletions
+147 -3
View File
@@ -22,7 +22,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
}) as never,
);
const upstream = { getCandles: jest.fn() };
const access = new TbankAccessService(prisma, tokens, upstream as never);
const access = new TbankAccessService(prisma, tokens);
const instrument = {
uid: 'uid',
currency: 'rub',
@@ -46,10 +46,10 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
},
]),
};
const createService = () =>
const createService = (selectedSources = sources) =>
new CandleService(
repository,
sources as never,
selectedSources as never,
access,
upstream as never,
{ event: jest.fn() } as never,
@@ -112,6 +112,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
});
});
afterAll(async () => prisma.$disconnect());
afterEach(() => jest.restoreAllMocks());
it('deduplicates seams, excludes outside rows, persists exact decimals and closed empty coverage after reopen', async () => {
const attempt = await reserve();
await repository.stage(
@@ -302,6 +303,149 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
expect(read.meta.sync.state).toBe('failed');
expect(read.data?.candles).toEqual([]);
});
it.each([
['owner', 'before loading', false],
['observer', 'before loading', false],
['owner', 'after ordinary publication', false],
['observer', 'after ordinary publication', false],
['observer', 'after ordinary publication', true],
] as const)(
'honors force from %s joining %s (later failure=%s)',
async (userId, phase, laterFailure) => {
if (userId !== owner.userId) {
await prisma.user.upsert({
where: { id: userId },
create: {
id: userId,
email: `${userId}@example.invalid`,
passwordHash: 'synthetic',
},
update: {},
});
await prisma.tbankConnection.create({
data: {
userId,
revision: 'revision',
encryptedToken: tokens.encrypt('synthetic'),
connectedAt: new Date(),
},
});
}
const history = {
...instrument,
first_1dayCandleDate: {
seconds: String(Date.parse('2024-01-01Z') / 1000),
},
};
const covered = {
from: new Date('2024-01-01Z'),
to: new Date('2025-01-01Z'),
};
const old = { ...candle, time: '2024-01-02T00:00:00.000Z' };
const initial = await reserve([covered]);
await repository.stage(initial, covered, [old], new Date('2025-01-02Z'));
await repository.publish(initial, { uid: 'uid', ranges: [covered] });
let entered = () => {};
let release = () => {};
const started = new Promise<void>((resolve) => (entered = resolve));
const blocked = new Promise<void>((resolve) => (release = resolve));
const selectedSources = {
...sources,
ensureDetail: jest.fn().mockResolvedValue(history),
};
if (phase === 'before loading')
selectedSources.ensureDetail.mockImplementationOnce(async () => {
entered();
await blocked;
return history;
});
else {
const publish = repository.publish.bind(repository);
jest
.spyOn(repository, 'publish')
.mockImplementationOnce(async (...args) => {
const result = await publish(...args);
entered();
await blocked;
return result;
});
}
const forcedAt = new Date('2026-02-03T13:00:00Z');
const forcedStates: string[] = [];
const fullRange = { from: covered.from, to: new Date('2026-02-04Z') };
let beforeForced:
Awaited<ReturnType<CandleRepository['read']>> | undefined;
upstream.getCandles.mockImplementation(
async (_token, _uid, _from, to: Date) => {
if (laterFailure && forcedStates.length)
throw new Error('Forced reconciliation second window unavailable');
if (to.valueOf() === forcedAt.valueOf()) {
beforeForced = await repository.read('uid', fullRange);
const work = await repository.work(
userId,
'revision',
'candles:uid:all',
);
forcedStates.push(work?.state ?? 'missing');
}
return [
{
time: { seconds: String(Date.parse(old.time) / 1000) },
close: { units: '200', nano: 1 },
isComplete: true,
},
];
},
);
const service = createService(selectedSources);
const input = {
uid: 'uid',
instrumentType: 'share' as const,
period: 'all' as const,
};
const ordinaryId = await service.request(
'owner',
'revision',
input,
new Date('2026-02-02T12:00:00Z'),
false,
2,
);
await started;
const forcedId = await service.request(
userId,
'revision',
input,
forcedAt,
true,
0,
);
release();
await settled();
if (laterFailure) {
expect(beforeForced).toBeDefined();
expect(await repository.read('uid', fullRange)).toEqual(beforeForced);
} else
expect((await repository.read('uid', covered)).data).toEqual([
{ ...old, close: '200.000000001' },
]);
expect(forcedStates).toEqual(['running']);
expect(
await repository.work(userId, 'revision', 'candles:uid:all'),
).toMatchObject({
id: forcedId,
state: laterFailure ? 'failed' : 'complete',
priority: 0,
intent: {
force: true,
now: forcedAt.toISOString(),
},
});
if (userId === owner.userId) expect(forcedId).toBe(ordinaryId);
else expect(forcedId).not.toBe(ordinaryId);
},
);
it('preserves source incompleteness and bond percentage precision without nominal conversion', async () => {
const now = new Date();
now.setUTCHours(0, 0, 0, 0);