import { INestApplication } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import { Test, TestingModule } from '@nestjs/testing'; import { PARAMS_PROVIDER_TOKEN } from 'nestjs-pino'; import request from 'supertest'; import { App } from 'supertest/types'; import { AppModule } from '../src/app.module'; import { loggingOptions } from '../src/common/logging/logging.module'; import { configureApp } from '../src/main'; import { AuthService } from '../src/modules/auth/application/auth.service'; import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler'; import { PrismaService } from '../src/prisma/prisma.service'; type Authentication = { accessToken: string; user: { email: string } }; type Problem = { code: string }; describe('Authentication (e2e)', () => { let app: INestApplication; const logs: Record[] = []; beforeEach(async () => { logs.length = 0; const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }).overrideProvider(PARAMS_PROVIDER_TOKEN).useFactory({ inject: [ConfigService], factory: (config: Parameters[0]) => ({ pinoHttp: { ...loggingOptions(config), level: 'info', stream: { write: (line: string) => logs.push(JSON.parse(line) as Record) }, }, assignResponse: true, }), }) .overrideProvider(TbankHourlyScheduler) .useValue({ tick: jest.fn() }) .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((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((a, b) => a - b)).toEqual([ 200, 401, ]); const winner = responses.find((response) => response.status === 200); if (!winner) throw new Error('Expected one successful rotation'); expect(responses.find((response) => response.status === 401)?.headers['set-cookie']).toBeUndefined(); await request(app.getHttpServer()) .post('/api/v1/auth/refresh') .set('Cookie', cookie) .expect(401) .expect((response) => expect(response.headers['set-cookie']).toBeUndefined()); 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 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(logs.filter((entry) => entry.requestId === response.headers['x-request-id'])).toEqual([ expect.objectContaining({ level: 40, message: 'http.request.completed', status: 401, code: 'UNAUTHORIZED' }), ]); }); it('logs an unhandled error with safe public context and correlation', async () => { 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' }), ); const completed = logs.filter((entry) => entry.requestId === response.headers['x-request-id']); expect(completed).toEqual([ expect.objectContaining({ requestId: response.headers['x-request-id'], clientRequestId, status: 500, code: 'INTERNAL_SERVER_ERROR', level: 50, message: 'http.request.completed', }), ]); expect(JSON.stringify(logs)).not.toContain( 'SYNTHETIC_AUDIT_SECRET', ); }); });