Files
backend/test/app.e2e-spec.ts
T

360 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 = { accessToken: string; user: { email: 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((a, b) => a - b)).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',
);
});
});