Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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')
Expand All @@ -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');
}
Expand Down
14 changes: 11 additions & 3 deletions backend/src/shared/filters/all-exceptions.filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,25 @@ 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,
path: request.url,
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);
Expand Down
80 changes: 80 additions & 0 deletions backend/test/logging/exception-logging.spec.ts
Original file line number Diff line number Diff line change
@@ -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'),
}),
],
},
]);
});
});
50 changes: 50 additions & 0 deletions backend/test/refresh-token/writes.spec.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof createTestApp>>;
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);
});
});
});
14 changes: 11 additions & 3 deletions backend/test/utils/mocks/logger-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down
Loading