582 lines
23 KiB
TypeScript
582 lines
23 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 { 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<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('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 <T>(nodeEnv: string, run: () => Promise<T>) => {
|
|
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<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 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',
|
|
);
|
|
});
|
|
});
|