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.
359 lines
13 KiB
TypeScript
359 lines
13 KiB
TypeScript
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';
|
|
import { StructuredLogger } from '../src/common/logging/structured-logger.service';
|
|
import { configureApp } from '../src/main';
|
|
import { AuthService } from '../src/modules/auth/application/auth.service';
|
|
import { PrismaService } from '../src/prisma/prisma.service';
|
|
|
|
type Authentication = { user: { email: string }; accessToken: string };
|
|
type Problem = { code: string };
|
|
|
|
describe('Authentication (e2e)', () => {
|
|
let app: INestApplication<App>;
|
|
|
|
beforeEach(async () => {
|
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
|
imports: [AppModule],
|
|
}).compile();
|
|
|
|
app = moduleFixture.createNestApplication();
|
|
configureApp(app);
|
|
await app.listen(0, '127.0.0.1');
|
|
await app.get(PrismaService).session.deleteMany();
|
|
await app.get(PrismaService).tbankConnection.deleteMany();
|
|
await app.get(PrismaService).user.deleteMany();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await app.close();
|
|
jest.restoreAllMocks();
|
|
});
|
|
|
|
it('registers, guards profile, rotates refresh and logs out', async () => {
|
|
const agent = request.agent(app.getHttpServer());
|
|
const registration = await agent
|
|
.post('/api/v1/auth/register')
|
|
.send({ email: ' User@Example.com ', password: 'a secure password' })
|
|
.expect(200);
|
|
|
|
const authentication = registration.body as Authentication;
|
|
expect(authentication.user.email).toBe('user@example.com');
|
|
const accessToken = authentication.accessToken;
|
|
await request(app.getHttpServer()).get('/api/v1/auth/me').expect(401);
|
|
await agent
|
|
.get('/api/v1/auth/me')
|
|
.set('Authorization', `Bearer ${accessToken}`)
|
|
.expect(200)
|
|
.expect(({ body }: { body: unknown }) =>
|
|
expect((body as { email: string }).email).toBe('user@example.com'),
|
|
);
|
|
|
|
const firstCookie = registration.headers['set-cookie'][0];
|
|
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);
|
|
});
|
|
|
|
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 () => {
|
|
const registration = await request(app.getHttpServer())
|
|
.post('/api/v1/auth/register')
|
|
.send({ email: 'race@example.test', password: 'a secure password' })
|
|
.expect(200);
|
|
const cookie = registration.headers['set-cookie'][0];
|
|
// Both requests read the old hash before either reaches the real SQLite CAS.
|
|
const jwt = app.get(JwtService);
|
|
const sign = jwt.signAsync.bind(jwt);
|
|
let arrived = 0;
|
|
let release = () => {};
|
|
const ready = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
const barrier = jest
|
|
.spyOn(jwt, 'signAsync')
|
|
.mockImplementation(async (...args) => {
|
|
const signed = await sign(...args);
|
|
if (
|
|
typeof args[0] === 'object' &&
|
|
'type' in args[0] &&
|
|
args[0].type === 'refresh'
|
|
) {
|
|
if (++arrived === 2) release();
|
|
await ready;
|
|
}
|
|
return signed;
|
|
});
|
|
const responses = await Promise.all(
|
|
Array.from({ length: 2 }, () =>
|
|
request(app.getHttpServer())
|
|
.post('/api/v1/auth/refresh')
|
|
.set('Cookie', cookie),
|
|
),
|
|
);
|
|
barrier.mockRestore();
|
|
expect(arrived).toBe(2);
|
|
expect(responses.map((response) => response.status).sort()).toEqual([
|
|
200, 401,
|
|
]);
|
|
const winner = responses.find((response) => response.status === 200);
|
|
if (!winner) throw new Error('Expected one successful rotation');
|
|
await request(app.getHttpServer())
|
|
.post('/api/v1/auth/refresh')
|
|
.set('Cookie', cookie)
|
|
.expect(401);
|
|
await request(app.getHttpServer())
|
|
.post('/api/v1/auth/refresh')
|
|
.set('Cookie', winner.headers['set-cookie'][0])
|
|
.expect(200);
|
|
expect(await app.get(PrismaService).session.count()).toBe(1);
|
|
});
|
|
|
|
it('rejects duplicate registration and invalid credentials', async () => {
|
|
await request(app.getHttpServer())
|
|
.post('/api/v1/auth/register')
|
|
.send({ email: 'user@example.com', password: 'a secure password' })
|
|
.expect(200);
|
|
await request(app.getHttpServer())
|
|
.post('/api/v1/auth/login')
|
|
.send({ email: 'user@example.com', password: 'a secure password' })
|
|
.expect(200)
|
|
.expect(({ body }: { body: unknown }) =>
|
|
expect((body as Authentication).user.email).toBe('user@example.com'),
|
|
);
|
|
await request(app.getHttpServer())
|
|
.post('/api/v1/auth/register')
|
|
.send({ email: 'USER@example.com', password: 'a secure password' })
|
|
.expect(409)
|
|
.expect(({ body }: { body: unknown }) =>
|
|
expect((body as Problem).code).toBe('EMAIL_ALREADY_REGISTERED'),
|
|
);
|
|
await request(app.getHttpServer())
|
|
.post('/api/v1/auth/login')
|
|
.send({ email: 'user@example.com', password: 'wrong password' })
|
|
.expect(401)
|
|
.expect(({ body }: { body: unknown }) =>
|
|
expect((body as Problem).code).toBe('INVALID_CREDENTIALS'),
|
|
);
|
|
});
|
|
|
|
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')
|
|
.send({ email: 'connection@example.com', password: 'a secure password' })
|
|
.expect(200);
|
|
const accessToken = (registration.body as Authentication).accessToken;
|
|
|
|
await request(app.getHttpServer())
|
|
.get('/api/v1/tbank-connection')
|
|
.expect(401);
|
|
await request(app.getHttpServer())
|
|
.get('/api/v1/tbank-connection')
|
|
.set('Authorization', `Bearer ${accessToken}`)
|
|
.expect(200)
|
|
.expect(({ body }: { body: unknown }) =>
|
|
expect(body).toEqual({ connected: false, connectedAt: null }),
|
|
);
|
|
});
|
|
|
|
it('guards brokerage accounts and reports a missing connection', async () => {
|
|
const registration = await request(app.getHttpServer())
|
|
.post('/api/v1/auth/register')
|
|
.send({ email: 'accounts@example.com', password: 'a secure password' })
|
|
.expect(200);
|
|
const accessToken = (registration.body as Authentication).accessToken;
|
|
|
|
await request(app.getHttpServer())
|
|
.get('/api/v1/brokerage-accounts')
|
|
.expect(401);
|
|
await request(app.getHttpServer())
|
|
.get('/api/v1/brokerage-accounts')
|
|
.set('Authorization', `Bearer ${accessToken}`)
|
|
.expect(409)
|
|
.expect(({ body }: { body: unknown }) =>
|
|
expect((body as Problem).code).toBe('TBANK_NOT_CONNECTED'),
|
|
);
|
|
});
|
|
|
|
it('guards portfolio and reports a missing connection', async () => {
|
|
const registration = await request(app.getHttpServer())
|
|
.post('/api/v1/auth/register')
|
|
.send({ email: 'portfolio@example.com', password: 'a secure password' })
|
|
.expect(200);
|
|
const accessToken = (registration.body as Authentication).accessToken;
|
|
|
|
await request(app.getHttpServer())
|
|
.get('/api/v1/brokerage-accounts/account/portfolio')
|
|
.expect(401);
|
|
await request(app.getHttpServer())
|
|
.get('/api/v1/brokerage-accounts/account/portfolio')
|
|
.set('Authorization', `Bearer ${accessToken}`)
|
|
.expect(409)
|
|
.expect(({ body }: { body: unknown }) =>
|
|
expect((body as Problem).code).toBe('TBANK_NOT_CONNECTED'),
|
|
);
|
|
});
|
|
|
|
it('exposes a server request ID and logs one completion event', async () => {
|
|
const logger = app.get(StructuredLogger);
|
|
const event = jest.spyOn(logger, 'event');
|
|
const response = await request(app.getHttpServer())
|
|
.get('/api/v1/auth/me')
|
|
.set('Origin', 'http://localhost:5173')
|
|
.expect(401);
|
|
|
|
expect(response.headers['x-request-id']).toMatch(/^[0-9a-f-]{36}$/i);
|
|
expect(response.headers['access-control-expose-headers']).toContain(
|
|
'X-Request-ID',
|
|
);
|
|
expect(event).toHaveBeenCalledTimes(1);
|
|
expect(event).toHaveBeenCalledWith(
|
|
'warn',
|
|
'http.request.completed',
|
|
expect.objectContaining({ status: 401, code: 'UNAUTHORIZED' }),
|
|
);
|
|
});
|
|
|
|
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('operation failed\npassword: SYNTHETIC_AUDIT_SECRET'),
|
|
);
|
|
|
|
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(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',
|
|
);
|
|
});
|
|
});
|