245 lines
8.9 KiB
TypeScript
245 lines
8.9 KiB
TypeScript
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<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(() => 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<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('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',
|
|
}),
|
|
);
|
|
});
|
|
});
|