import { INestApplication } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import { Test, TestingModule } from '@nestjs/testing'; import * as bcrypt from 'bcrypt'; import { PARAMS_PROVIDER_TOKEN } from 'nestjs-pino'; import { createHash, randomUUID } from 'node:crypto'; 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('rejects a valid refresh token as Bearer while its session stays usable', async () => { const registration = await request(app.getHttpServer()) .post('/api/v1/auth/register') .send({ email: 'bearer@example.test', password: 'a secure password' }) .expect(200); const cookie = registration.headers['set-cookie'][0]; const refreshToken = /^refresh_token=([^;]+)/.exec(cookie)?.[1]; if (!refreshToken) throw new Error('Expected a refresh token cookie'); // Signed, unexpired and backed by a live session: only its type is wrong. await expect(app.get(JwtService).verifyAsync(refreshToken)).resolves.toMatchObject({ type: 'refresh', }); await request(app.getHttpServer()) .get('/api/v1/auth/me') .set('Authorization', `Bearer ${refreshToken}`) .expect(401) .expect(({ body }: { body: unknown }) => expect(body).toEqual({ status: 401, code: 'UNAUTHORIZED' }), ); await request(app.getHttpServer()) .post('/api/v1/auth/refresh') .set('Cookie', cookie) .expect(200); }); describe('refresh cookie policy', () => { const attributes = (cookie: string) => cookie .split('; ') .slice(1) .filter((attribute) => !attribute.startsWith('Expires=')) .sort((a, b) => a.localeCompare(b)); const host = ['HttpOnly', 'Path=/api/v1/auth', 'SameSite=Lax']; const login = async () => { const registration = await request(app.getHttpServer()) .post('/api/v1/auth/register') .send({ email: 'cookie@example.test', password: 'a secure password' }) .expect(200); return registration.headers['set-cookie'][0]; }; // The cookie policy reads NODE_ENV per request; the e2e runner defaults to development. const inEnvironment = async (nodeEnv: string, run: () => Promise) => { const previous = process.env['NODE_ENV']; process.env['NODE_ENV'] = nodeEnv; try { return await run(); } finally { process.env['NODE_ENV'] = previous; } }; it('issues, rotates and clears it HttpOnly, Lax, path-scoped, host-only and Secure in production', async () => { await inEnvironment('production', async () => { const issued = await login(); const rotated = await request(app.getHttpServer()) .post('/api/v1/auth/refresh') .set('Cookie', issued) .expect(200); const cleared = await request(app.getHttpServer()) .post('/api/v1/auth/logout') .set('Cookie', rotated.headers['set-cookie'][0]) .expect(204); // Exact set: any added Domain/Max-Age or a dropped flag fails. for (const cookie of [issued, rotated.headers['set-cookie'][0], cleared.headers['set-cookie'][0]]) { expect(attributes(cookie)).toEqual([...host, 'Secure'].sort((a, b) => a.localeCompare(b))); } }); }); it('drops only Secure in development and keeps every other attribute', async () => { await inEnvironment('development', async () => { expect(attributes(await login())).toEqual(host); }); }); }); describe('expired credentials', () => { const sha256 = (token: string) => createHash('sha256').update(token).digest('hex'); const PAST = () => Math.floor(Date.now() / 1000) - 60; const FUTURE = () => Math.floor(Date.now() / 1000) + 600; const register = async () => { const registration = await request(app.getHttpServer()) .post('/api/v1/auth/register') .send({ email: 'expiry@example.test', password: 'a secure password' }) .expect(200); const session = await app.get(PrismaService).session.findFirstOrThrow(); return { cookie: registration.headers['set-cookie'][0], sub: session.userId, sid: session.id, }; }; it('rejects a signed expired access token but admits the same claims unexpired', async () => { const { sub, sid } = await register(); const jwt = app.get(JwtService); const expired = await jwt.signAsync({ sub, sid, type: 'access', exp: PAST() }); const fresh = await jwt.signAsync({ sub, sid, type: 'access', exp: FUTURE() }); await request(app.getHttpServer()) .get('/api/v1/auth/me') .set('Authorization', `Bearer ${expired}`) .expect(401); await request(app.getHttpServer()) .get('/api/v1/auth/me') .set('Authorization', `Bearer ${fresh}`) .expect(200) .expect(({ body }: { body: unknown }) => expect((body as { email: string }).email).toBe('expiry@example.test'), ); }); it('rejects a signed expired refresh token that matches the live session hash', async () => { const { sub, sid } = await register(); const jwt = app.get(JwtService); const prisma = app.get(PrismaService); const claims = { sub, sid, type: 'refresh' }; const expired = await jwt.signAsync({ ...claims, jti: randomUUID(), exp: PAST() }); const fresh = await jwt.signAsync({ ...claims, jti: randomUUID(), exp: FUTURE() }); // Hash, session and signature all valid: only `exp` differs between the two. await prisma.session.update({ where: { id: sid }, data: { refreshTokenHash: sha256(expired) } }); const rejected = await request(app.getHttpServer()) .post('/api/v1/auth/refresh') .set('Cookie', `refresh_token=${expired}`) .expect(401); expect(rejected.headers['set-cookie']).toBeUndefined(); await prisma.session.update({ where: { id: sid }, data: { refreshTokenHash: sha256(fresh) } }); await request(app.getHttpServer()) .post('/api/v1/auth/refresh') .set('Cookie', `refresh_token=${fresh}`) .expect(200); }); it('rejects a valid refresh token once its session is DB-expired', async () => { const { cookie, sid } = await register(); const prisma = app.get(PrismaService); await prisma.session.update({ where: { id: sid }, data: { expiresAt: new Date(Date.now() - 1000) } }); const rejected = await request(app.getHttpServer()) .post('/api/v1/auth/refresh') .set('Cookie', cookie) .expect(401); expect(rejected.headers['set-cookie']).toBeUndefined(); expect(await prisma.session.count()).toBe(0); await request(app.getHttpServer()) .post('/api/v1/auth/refresh') .set('Cookie', cookie) .expect(401); }); }); 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('applies the ASCII 8..72 password policy on register and keeps login lenient', async () => { const validation = (detail: string) => ({ status: 400, code: 'VALIDATION_ERROR', errors: [{ pointer: '#/password', detail }], }); for (const [index, password] of ['a'.repeat(8), ' '.repeat(2) + 'b'.repeat(70)].entries()) { const credentials = { email: `user${index}@example.com`, password }; await request(app.getHttpServer()).post('/api/v1/auth/register').send(credentials).expect(200); await request(app.getHttpServer()).post('/api/v1/auth/login').send(credentials).expect(200); } for (const password of ['a'.repeat(7), 'a'.repeat(73), 'пароль-пароль', 'passw😀rd', 'pass\tword1']) { await request(app.getHttpServer()) .post('/api/v1/auth/register') .send({ email: 'rejected@example.com', password }) .expect(400) .expect('Content-Type', /application\/problem\+json/) .expect(({ body }: { body: unknown }) => expect(body).toEqual(validation('must be 8-72 printable ASCII characters')), ); } expect(await app.get(PrismaService).user.count()).toBe(2); // An account created under the previous policy (Cyrillic, 12+ chars) must still log in. const legacy = { email: 'legacy@example.com', password: 'пароль-пароль' }; const prisma = app.get(PrismaService); await prisma.user.create({ data: { id: randomUUID(), email: legacy.email, passwordHash: await bcrypt.hash(legacy.password, 4) }, }); await request(app.getHttpServer()).post('/api/v1/auth/login').send(legacy).expect(200); await request(app.getHttpServer()) .post('/api/v1/auth/login') .send({ email: legacy.email, password: 'a'.repeat(73) }) .expect(400) .expect(({ body }: { body: unknown }) => expect(body).toEqual(validation('must not exceed 72 UTF-8 bytes')), ); }); 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 related = logs.filter((entry) => entry.requestId === response.headers['x-request-id']); expect(related).toEqual([ expect.objectContaining({ requestId: response.headers['x-request-id'], clientRequestId, code: 'INTERNAL_SERVER_ERROR', level: 50, message: 'http.request.unhandled', errorName: 'Error', errorStack: expect.stringContaining(' at ') as unknown, }), 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', ); }); });