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
+125 -11
View File
@@ -1,6 +1,7 @@
import { INestApplication } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test, TestingModule } from '@nestjs/testing';
import { PinoLogger } from 'nestjs-pino';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from '../src/app.module';
@@ -28,7 +29,10 @@ describe('Authentication (e2e)', () => {
await app.get(PrismaService).user.deleteMany();
});
afterEach(() => app.close());
afterEach(async () => {
await app.close();
jest.restoreAllMocks();
});
it('registers, guards profile, rotates refresh and logs out', async () => {
const agent = request.agent(app.getHttpServer());
@@ -50,14 +54,94 @@ describe('Authentication (e2e)', () => {
);
const firstCookie = registration.headers['set-cookie'][0];
await agent.post('/api/v1/auth/refresh').expect(200);
const refreshed = await agent.post('/api/v1/auth/refresh').expect(200);
await request(app.getHttpServer())
.post('/api/v1/auth/refresh')
.set('Cookie', firstCookie)
.expect(401)
.expect('Content-Type', /application\/problem\+json/);
const logout = await agent.post('/api/v1/auth/logout').expect(204);
expect(logout.headers['set-cookie'][0]).toContain('refresh_token=;');
await request(app.getHttpServer())
.post('/api/v1/auth/refresh')
.set('Cookie', refreshed.headers['set-cookie'][0])
.expect(401)
.expect(({ body }: { body: unknown }) =>
expect(body).toEqual({ status: 401, code: 'UNAUTHORIZED' }),
);
await request(app.getHttpServer())
.post('/api/v1/auth/logout')
.set('Cookie', refreshed.headers['set-cookie'][0])
.expect(204);
await agent.post('/api/v1/auth/logout').expect(204);
await agent.post('/api/v1/auth/logout').expect(204);
});
it('keeps logout idempotent for an invalid refresh token', async () => {
const logout = await request(app.getHttpServer())
.post('/api/v1/auth/logout')
.set('Cookie', 'refresh_token=invalid')
.expect(204);
expect(logout.headers['set-cookie'][0]).toContain('refresh_token=;');
});
it('reports a failed revocation and preserves the refresh cookie for retry', async () => {
const registration = await request(app.getHttpServer())
.post('/api/v1/auth/register')
.send({ email: 'logout@example.test', password: 'a secure password' })
.expect(200);
const cookie = registration.headers['set-cookie'][0];
const revoke = jest
.spyOn(app.get(PrismaService).session, 'update')
.mockRejectedValueOnce(new Error('Storage unavailable'));
const failed = await request(app.getHttpServer())
.post('/api/v1/auth/logout')
.set('Cookie', cookie)
.expect(500)
.expect('Content-Type', /application\/problem\+json/);
revoke.mockRestore();
expect(failed.body).toEqual({
status: 500,
code: 'INTERNAL_SERVER_ERROR',
});
expect(failed.headers['set-cookie']).toBeUndefined();
expect(
await app.get(PrismaService).session.findFirstOrThrow(),
).toMatchObject({ revokedAt: null });
await request(app.getHttpServer())
.post('/api/v1/auth/refresh')
.set('Cookie', cookie)
.expect(200);
});
it('preserves a valid refresh cookie during a storage failure', async () => {
const registration = await request(app.getHttpServer())
.post('/api/v1/auth/register')
.send({ email: 'refresh@example.test', password: 'a secure password' })
.expect(200);
const cookie = registration.headers['set-cookie'][0];
const read = jest
.spyOn(app.get(PrismaService).session, 'findUnique')
.mockRejectedValueOnce(new Error('Storage unavailable'));
const failed = await request(app.getHttpServer())
.post('/api/v1/auth/refresh')
.set('Cookie', cookie)
.expect(500)
.expect('Content-Type', /application\/problem\+json/);
read.mockRestore();
expect(failed.body).toEqual({
status: 500,
code: 'INTERNAL_SERVER_ERROR',
});
expect(failed.headers['set-cookie']).toBeUndefined();
await request(app.getHttpServer())
.post('/api/v1/auth/refresh')
.set('Cookie', cookie)
.expect(200);
});
it('rotates one concurrent refresh on SQLite and keeps the winner usable', async () => {
@@ -141,6 +225,26 @@ describe('Authentication (e2e)', () => {
);
});
it('returns field errors for invalid credentials without exposing input', async () => {
const response = await request(app.getHttpServer())
.post('/api/v1/auth/register')
.send({ email: 'SYNTHETIC_VALIDATION_SECRET' })
.expect(400)
.expect('Content-Type', /application\/problem\+json/);
expect(response.body).toEqual({
status: 400,
code: 'VALIDATION_ERROR',
errors: expect.arrayContaining([
{ pointer: '#/email', detail: 'must be a valid email address' },
{ pointer: '#/password', detail: 'password must be a string' },
]) as unknown,
});
expect(JSON.stringify(response.body).toLowerCase()).not.toContain(
'synthetic_validation_secret',
);
expect(await app.get(PrismaService).user.count()).toBe(0);
});
it('guards T-Bank connection and never returns a token', async () => {
const registration = await request(app.getHttpServer())
.post('/api/v1/auth/register')
@@ -218,27 +322,37 @@ describe('Authentication (e2e)', () => {
);
});
it('records the public code and stack for an unhandled error', async () => {
const logger = app.get(StructuredLogger);
const event = jest.spyOn(logger, 'event');
it('logs an unhandled error with safe public context and correlation', async () => {
const error = jest.spyOn(PinoLogger.prototype, 'error');
const clientRequestId = 'a2dc6fbb-5a48-4c3c-9e3e-4fbf4d638a43';
jest
.spyOn(app.get(AuthService), 'login')
.mockRejectedValue(new Error('password: secret'));
.mockRejectedValue(
new Error('operation failed\npassword: SYNTHETIC_AUDIT_SECRET'),
);
await request(app.getHttpServer())
const response = await request(app.getHttpServer())
.post('/api/v1/auth/login')
.set('X-Request-ID', clientRequestId)
.send({ email: 'user@example.com', password: 'a secure password' })
.expect(500)
.expect('Content-Type', /application\/problem\+json/)
.expect(({ body }: { body: unknown }) =>
expect(body).toEqual({ status: 500, code: 'INTERNAL_SERVER_ERROR' }),
);
expect(event).toHaveBeenCalledWith(
'error',
'http.request.completed',
expect(error).toHaveBeenCalledTimes(1);
expect(error).toHaveBeenCalledWith(
expect.objectContaining({
requestId: response.headers['x-request-id'],
clientRequestId,
status: 500,
code: 'INTERNAL_SERVER_ERROR',
}),
'http.request.completed',
);
expect(JSON.stringify(error.mock.calls)).not.toContain(
'SYNTHETIC_AUDIT_SECRET',
);
});
});