diff --git a/backend/src/auth/refresh-token/core/refresh-token-core.controller.ts b/backend/src/auth/refresh-token/core/refresh-token-core.controller.ts index cb97d9d1..387563fd 100644 --- a/backend/src/auth/refresh-token/core/refresh-token-core.controller.ts +++ b/backend/src/auth/refresh-token/core/refresh-token-core.controller.ts @@ -1,3 +1,4 @@ +import { Logger } from '@logdash/js-sdk'; import { Body, Controller, HttpCode, Post, UnauthorizedException } from '@nestjs/common'; import { ApiCreatedResponse, ApiTags } from '@nestjs/swagger'; import { TokenResponse } from '../../../shared/responses/token.response'; @@ -20,6 +21,7 @@ export class RefreshTokenCoreController { private readonly jwtService: CustomJwtService, private readonly userReadService: UserReadService, private readonly productAnalytics: ProductAnalyticsService, + private readonly logger: Logger, ) {} @Post('refresh') @@ -38,6 +40,7 @@ export class RefreshTokenCoreController { if (existing.revokedAt !== null) { // Reuse of a revoked token — possible theft. Revoke all tokens for the user. + this.logger.warn('Refresh token reuse detected', { userId: existing.userId }); await this.refreshTokenWriteService.revokeAllForUser(existing.userId); throw new UnauthorizedException('Refresh token has been revoked'); } diff --git a/backend/src/shared/filters/all-exceptions.filter.ts b/backend/src/shared/filters/all-exceptions.filter.ts index c6f7e3d3..64e2dda3 100644 --- a/backend/src/shared/filters/all-exceptions.filter.ts +++ b/backend/src/shared/filters/all-exceptions.filter.ts @@ -28,7 +28,7 @@ export class AllExceptionsFilter extends BaseExceptionFilter { const errorResponse = exception instanceof HttpException ? exception.getResponse() : null; - this.logger.error('Unhandled Exception', { + const details = { statusCode: status, message, method: request.method, @@ -36,9 +36,17 @@ export class AllExceptionsFilter extends BaseExceptionFilter { timestamp: new Date().toISOString(), userId: (request as any).user?.id, error: exception instanceof Error ? exception.message : String(exception), - stack: exception instanceof Error ? exception.stack : undefined, responseBody: errorResponse, - }); + }; + + if (status < HttpStatus.INTERNAL_SERVER_ERROR) { + this.logger.http('Request rejected', details); + } else { + this.logger.error('Unhandled Exception', { + ...details, + stack: exception instanceof Error ? exception.stack : undefined, + }); + } } super.catch(exception, host); diff --git a/backend/test/logging/exception-logging.spec.ts b/backend/test/logging/exception-logging.spec.ts new file mode 100644 index 00000000..db47df40 --- /dev/null +++ b/backend/test/logging/exception-logging.spec.ts @@ -0,0 +1,80 @@ +import { Logger } from '@logdash/js-sdk'; +import { Controller, Get, INestApplication, UnauthorizedException } from '@nestjs/common'; +import { HttpAdapterHost } from '@nestjs/core'; +import { Test } from '@nestjs/testing'; +import * as request from 'supertest'; +import { AllExceptionsFilter } from '../../src/shared/filters/all-exceptions.filter'; +import { OurEnv } from '../../src/shared/types/our-env.enum'; +import { LoggerMock } from '../utils/mocks/logger-mock'; + +@Controller() +class FailingController { + @Get('expired-session') + public expiredSession(): void { + throw new UnauthorizedException(); + } + + @Get('crash') + public crash(): void { + throw new Error('boom'); + } +} + +describe('Exception logging', () => { + const originalEnv = process.env.OUR_ENV; + let app: INestApplication; + let logger: LoggerMock; + + beforeAll(async () => { + process.env.OUR_ENV = OurEnv.Prod; + const module = await Test.createTestingModule({ + controllers: [FailingController], + }).compile(); + + logger = new LoggerMock(); + app = module.createNestApplication(); + app.useGlobalFilters( + new AllExceptionsFilter(logger as unknown as Logger, app.get(HttpAdapterHost).httpAdapter), + ); + await app.init(); + }); + + beforeEach(() => { + logger.entries.length = 0; + }); + + afterAll(async () => { + process.env.OUR_ENV = originalEnv; + await app.close(); + }); + + it('keeps a rejected request out of the error logs', async () => { + // when + const response = await request(app.getHttpServer()).get('/expired-session'); + + // then + expect(response.status).toEqual(401); + expect(logger.entries.map((entry) => entry.level)).toEqual(['http']); + }); + + it('reports a crash as an error with its stack', async () => { + // when + const response = await request(app.getHttpServer()).get('/crash'); + + // then + expect(response.status).toEqual(500); + expect(logger.entries).toEqual([ + { + level: 'error', + data: [ + 'Unhandled Exception', + expect.objectContaining({ + statusCode: 500, + path: '/crash', + stack: expect.stringContaining('boom'), + }), + ], + }, + ]); + }); +}); diff --git a/backend/test/refresh-token/writes.spec.ts b/backend/test/refresh-token/writes.spec.ts new file mode 100644 index 00000000..97b80cc8 --- /dev/null +++ b/backend/test/refresh-token/writes.spec.ts @@ -0,0 +1,50 @@ +import { Logger } from '@logdash/js-sdk'; +import * as request from 'supertest'; +import { RefreshTokenWriteService } from '../../src/auth/refresh-token/write/refresh-token-write.service'; +import { createTestApp } from '../utils/bootstrap'; +import { LoggerMock } from '../utils/mocks/logger-mock'; + +describe('RefreshTokenCoreController (writes)', () => { + let bootstrap: Awaited>; + let logger: LoggerMock; + + const refresh = (refreshToken: string) => + request(bootstrap.app.getHttpServer()).post('/auth/refresh').send({ refreshToken }); + + beforeAll(async () => { + bootstrap = await createTestApp(); + logger = bootstrap.module.get(Logger) as unknown as LoggerMock; + }); + + beforeEach(async () => { + await bootstrap.methods.beforeEach(); + logger.entries.length = 0; + }); + + afterAll(async () => { + await bootstrap.methods.afterAll(); + }); + + describe('POST /auth/refresh', () => { + it('warns without the token and ends the session when a rotated token is reused', async () => { + // given + const { user } = await bootstrap.utils.userUtils.createDefault(); + const { rawToken } = await bootstrap.module.get(RefreshTokenWriteService).issue(user.id); + const rotated = await refresh(rawToken); + + // when + const reuse = await refresh(rawToken); + + // then + expect(rotated.status).toEqual(201); + expect(reuse.status).toEqual(401); + expect(logger.entries).toEqual([ + { level: 'warn', data: ['Refresh token reuse detected', { userId: user.id }] }, + ]); + expect(JSON.stringify(logger.entries)).not.toContain(rawToken); + + const afterReuse = await refresh(rotated.body.refreshToken); + expect(afterReuse.status).toEqual(401); + }); + }); +}); diff --git a/backend/test/utils/mocks/logger-mock.ts b/backend/test/utils/mocks/logger-mock.ts index 1995fb3e..634a4f3a 100644 --- a/backend/test/utils/mocks/logger-mock.ts +++ b/backend/test/utils/mocks/logger-mock.ts @@ -2,11 +2,19 @@ import { Injectable } from '@nestjs/common'; @Injectable() export class LoggerMock { - error(): void {} - warn(): void {} + public readonly entries: { level: string; data: unknown[] }[] = []; + + error(...data: unknown[]): void { + this.entries.push({ level: 'error', data }); + } + warn(...data: unknown[]): void { + this.entries.push({ level: 'warn', data }); + } info(): void {} log(): void {} - http(): void {} + http(...data: unknown[]): void { + this.entries.push({ level: 'http', data }); + } verbose(): void {} debug(): void {} silly(): void {}