import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; 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 { PrismaService } from './../src/prisma/prisma.service'; import { AuthService } from './../src/auth/auth.service'; type Authentication = { user: { email: string }; accessToken: string }; type Problem = { code: string }; describe('Authentication (e2e)', () => { let app: INestApplication; 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(() => app.close()); 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]; 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/); await agent.post('/api/v1/auth/logout').expect(204); await agent.post('/api/v1/auth/logout').expect(204); }); 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()).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('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('records the public code and stack for an unhandled error', async () => { const logger = app.get(StructuredLogger); const event = jest.spyOn(logger, 'event'); jest .spyOn(app.get(AuthService), 'login') .mockRejectedValue(new Error('password: secret')); await request(app.getHttpServer()) .post('/api/v1/auth/login') .send({ email: 'user@example.com', password: 'a secure password' }) .expect(500) .expect(({ body }: { body: unknown }) => expect(body).toEqual({ status: 500, code: 'INTERNAL_SERVER_ERROR' }), ); expect(event).toHaveBeenCalledWith( 'error', 'http.request.completed', expect.objectContaining({ code: 'INTERNAL_SERVER_ERROR', }), ); }); });