Register accepts only printable ASCII (letters, digits, symbols, space), 8-72 characters, so characters equal bytes and the bcrypt 72-byte limit holds without silent truncation. Login only caps the password at 72 bytes so accounts created under the previous policy can still sign in. CredentialsDto is split into RegisterDto and LoginDto; OpenAPI publishes the register pattern and length bounds.
415 lines
16 KiB
TypeScript
415 lines
16 KiB
TypeScript
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 { 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<App>;
|
|
const logs: Record<string, unknown>[] = [];
|
|
|
|
beforeEach(async () => {
|
|
logs.length = 0;
|
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
|
imports: [AppModule],
|
|
}).overrideProvider(PARAMS_PROVIDER_TOKEN).useFactory({
|
|
inject: [ConfigService],
|
|
factory: (config: Parameters<typeof loggingOptions>[0]) => ({
|
|
pinoHttp: {
|
|
...loggingOptions(config),
|
|
level: 'info',
|
|
stream: { write: (line: string) => logs.push(JSON.parse(line) as Record<string, unknown>) },
|
|
},
|
|
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<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');
|
|
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 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',
|
|
);
|
|
});
|
|
});
|