Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Tnramalho feature/exceptions #294

Merged
merged 7 commits into from
Oct 17, 2024
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
1 change: 1 addition & 0 deletions packages/nestjs-auth-local/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"dependencies": {
"@concepta/nestjs-authentication": "^5.0.0-alpha.4",
"@concepta/nestjs-common": "^5.0.0-alpha.4",
"@concepta/nestjs-exception": "^5.0.0-alpha.4",
"@concepta/nestjs-password": "^5.0.0-alpha.4",
"@concepta/ts-common": "^5.0.0-alpha.4",
"@concepta/ts-core": "^5.0.0-alpha.4",
Expand Down
12 changes: 7 additions & 5 deletions packages/nestjs-auth-local/src/auth-local.strategy.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { randomUUID } from 'crypto';
import { mock } from 'jest-mock-extended';
import { PasswordValidationService } from '@concepta/nestjs-password';
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import { BadRequestException } from '@nestjs/common';
import { AuthLocalStrategy } from './auth-local.strategy';
import { AuthLocalSettingsInterface } from './interfaces/auth-local-settings.interface';
import { AuthLocalUserLookupServiceInterface } from './interfaces/auth-local-user-lookup-service.interface';
Expand All @@ -11,6 +11,8 @@ import { AuthLocalValidateUserService } from './services/auth-local-validate-use
import { UserFixture } from './__fixtures__/user/user.entity.fixture';
import { ReferenceIdInterface } from '@concepta/ts-core';
import { AuthLocalValidateUserInterface } from './interfaces/auth-local-validate-user.interface';
import { InvalidCredentialsException } from './exceptions/invalid-credentials.exception';
import { InvalidLoginDataException } from './exceptions/invalid-login-data.exception';

describe(AuthLocalStrategy.name, () => {
const USERNAME = 'username';
Expand Down Expand Up @@ -70,7 +72,7 @@ describe(AuthLocalStrategy.name, () => {
});

const t = () => authLocalStrategy.validate(USERNAME, PASSWORD);
await expect(t).rejects.toThrow(UnauthorizedException);
await expect(t).rejects.toThrow(InvalidCredentialsException);
});

it('should throw error on validateOrReject', async () => {
Expand All @@ -85,14 +87,14 @@ describe(AuthLocalStrategy.name, () => {
.mockRejectedValueOnce(BadRequestException);

const t = () => authLocalStrategy.validate(USERNAME, PASSWORD);
await expect(t).rejects.toThrow(BadRequestException);
await expect(t).rejects.toThrow(InvalidLoginDataException);
});

it('should return no user on userLookupService.byUsername', async () => {
jest.spyOn(userLookUpService, 'byUsername').mockResolvedValue(null);

const t = () => authLocalStrategy.validate(USERNAME, PASSWORD);
await expect(t).rejects.toThrow(UnauthorizedException);
await expect(t).rejects.toThrow(InvalidCredentialsException);
});

it('should be invalid on passwordService.validateObject', async () => {
Expand All @@ -101,7 +103,7 @@ describe(AuthLocalStrategy.name, () => {
.mockResolvedValue(false);

const t = () => authLocalStrategy.validate(USERNAME, PASSWORD);
await expect(t).rejects.toThrow(UnauthorizedException);
await expect(t).rejects.toThrow(InvalidCredentialsException);
});
});

Expand Down
17 changes: 9 additions & 8 deletions packages/nestjs-auth-local/src/auth-local.strategy.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
import { Strategy } from 'passport-local';
import { validateOrReject } from 'class-validator';
import {
BadRequestException,
Inject,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { ReferenceIdInterface, ReferenceUsername } from '@concepta/ts-core';
import { PassportStrategyFactory } from '@concepta/nestjs-authentication';

Expand All @@ -17,6 +12,8 @@ import {

import { AuthLocalSettingsInterface } from './interfaces/auth-local-settings.interface';
import { AuthLocalValidateUserServiceInterface } from './interfaces/auth-local-validate-user-service.interface';
import { InvalidCredentialsException } from './exceptions/invalid-credentials.exception';
import { InvalidLoginDataException } from './exceptions/invalid-login-data.exception';

/**
* Define the Local strategy using passport.
Expand Down Expand Up @@ -64,7 +61,9 @@ export class AuthLocalStrategy extends PassportStrategyFactory<Strategy>(
try {
await validateOrReject(dto);
} catch (e) {
throw new BadRequestException(e);
throw new InvalidLoginDataException({
originalError: e,
});
}

let validatedUser: ReferenceIdInterface;
Expand All @@ -81,7 +80,9 @@ export class AuthLocalStrategy extends PassportStrategyFactory<Strategy>(
}
} catch (e) {
// TODO: maybe log original?
throw new UnauthorizedException();
throw new InvalidCredentialsException({
originalError: e,
});
}

return validatedUser;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { HttpStatus } from '@nestjs/common';
import {
RuntimeException,
RuntimeExceptionOptions,
} from '@concepta/nestjs-exception';

export class InvalidCredentialsException extends RuntimeException {
constructor(options?: RuntimeExceptionOptions) {
super({
message: 'The provided credentials are incorrect. Please try again.',
httpStatus: HttpStatus.UNAUTHORIZED,
...options,
});

this.errorCode = 'AUTH_LOCAL_INVALID_CREDENTIALS_ERROR';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { HttpStatus } from '@nestjs/common';
import {
RuntimeException,
RuntimeExceptionOptions,
} from '@concepta/nestjs-exception';

export class InvalidLoginDataException extends RuntimeException {
constructor(options?: RuntimeExceptionOptions) {
super({
message:
'The provided username or password is incorrect. Please try again.',
httpStatus: HttpStatus.BAD_REQUEST,
...options,
});

this.errorCode = 'AUTH_LOCAL_INVALID_LOGIN_DATA_ERROR';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ export const ERROR_CODE_UNKNOWN = 'UNKNOWN';
export const ERROR_CODE_HTTP_UNKNOWN = 'HTTP_UNKNOWN';
export const ERROR_CODE_HTTP_BAD_REQUEST = 'HTTP_BAD_REQUEST';
export const ERROR_CODE_HTTP_NOT_FOUND = 'HTTP_NOT_FOUND';
export const ERROR_CODE_HTTP_UNAUTHORIZED = 'HTTP_UNAUTHORIZED';
export const ERROR_CODE_HTTP_INTERNAL_SERVER_ERROR =
'HTTP_INTERNAL_SERVER_ERROR';

export const ERROR_MESSAGE_FALLBACK = 'Internal Server Error';

// TODO: add remaining error codes
export const HTTP_ERROR_CODE = new Map<number, string>();
HTTP_ERROR_CODE.set(400, ERROR_CODE_HTTP_BAD_REQUEST);
HTTP_ERROR_CODE.set(401, ERROR_CODE_HTTP_UNAUTHORIZED);
HTTP_ERROR_CODE.set(404, ERROR_CODE_HTTP_NOT_FOUND);
HTTP_ERROR_CODE.set(500, ERROR_CODE_HTTP_INTERNAL_SERVER_ERROR);
2 changes: 2 additions & 0 deletions packages/nestjs-exception/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@ export { RuntimeExceptionInterface } from './interfaces/runtime-exception.interf

// exceptions
export { RuntimeException } from './exceptions/runtime.exception';
// utils
export { mapHttpStatus } from './utils/map-http-status.util';
1 change: 1 addition & 0 deletions packages/nestjs-logger-sentry/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
],
"dependencies": {
"@concepta/nestjs-common": "^5.0.0-alpha.4",
"@concepta/nestjs-exception": "^5.0.0-alpha.4",
"@concepta/nestjs-logger": "^5.0.0-alpha.4",
"@nestjs/common": "^10.4.1",
"@nestjs/config": "^3.2.3",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { RuntimeExceptionInterface } from '@concepta/nestjs-exception';

export interface LoggerSentryExtrasInterface
extends Partial<
Pick<RuntimeExceptionInterface, 'errorCode' | 'safeMessage' | 'context'>
> {
statusCode?: number;
message?: string | unknown;
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { LogLevel } from '@nestjs/common';
import { BadRequestException, HttpStatus, LogLevel } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import * as Sentry from '@sentry/node';

import { LOGGER_SENTRY_MODULE_SETTINGS_TOKEN } from '../config/logger-sentry.config';
import { LoggerSentryConfigInterface } from '../interfaces/logger-sentry-config.interface';
import { LoggerSentrySettingsInterface } from '../interfaces/logger-sentry-settings.interface';
import { LoggerSentryTransport } from './logger-sentry.transport';
import {
mapHttpStatus,
RuntimeException,
RuntimeExceptionOptions,
} from '@concepta/nestjs-exception';
import { isObject } from 'class-validator';

jest.mock('@sentry/node');

Expand Down Expand Up @@ -88,9 +94,8 @@ describe('loggerSentryTransport', () => {

/**
* Make sure call was made correctly
*
*/
it('LoggerSentryTransport.correctValues', async () => {
it('LoggerSentryTransport.correctValues Error', async () => {
const logLevel = 'log' as LogLevel;
const error = new Error();
loggerSentryTransport.log(errorMessage, logLevel, error);
Expand All @@ -100,7 +105,87 @@ describe('loggerSentryTransport', () => {
expect(spyCaptureException).toBeCalledTimes(1);
expect(spyCaptureException).toHaveBeenCalledWith(error, {
level: 'error',
extra: { developerMessage: errorMessage },
extra: {
developerMessage: errorMessage,
},
});
});

/**
* Test Log level map with capture message for string error
*/
it('LoggerSentryTransport.correctValues string error', async () => {
const logLevel = 'log' as LogLevel;
const error = 'Test error';
loggerSentryTransport.log(errorMessage, logLevel, error);

expect(spyLogLevelMap).toBeCalledTimes(1);
expect(spyLogLevelMap).toHaveBeenCalledWith(logLevel);
expect(spyCaptureException).toBeCalledTimes(1);
expect(spyCaptureException).toHaveBeenCalledWith(error, {
level: 'error',
extra: {
developerMessage: errorMessage,
},
});
});

/**
* Test Log level map with capture message for BadRequestException
*/
it('LoggerSentryTransport.correctValues BadRequestException', async () => {
const logLevel = 'log' as LogLevel;
const error = new BadRequestException();
const res = error.getResponse();
loggerSentryTransport.log(errorMessage, logLevel, error);

expect(spyLogLevelMap).toBeCalledTimes(1);
expect(spyLogLevelMap).toHaveBeenCalledWith(logLevel);
expect(spyCaptureException).toBeCalledTimes(1);
const statusCode = error.getStatus();
expect(spyCaptureException).toHaveBeenCalledWith(error, {
level: 'error',
extra: {
developerMessage: errorMessage,
statusCode,
errorCode: mapHttpStatus(statusCode as number),
message: isObject(res) && 'message' in res ? res.message : res,
},
});
});

/**
* Test Log level map with capture exception
*/
it('LoggerSentryTransport.correctValues Exception', async () => {
class TestException extends RuntimeException {
constructor(options?: RuntimeExceptionOptions) {
super({
message: 'Test Exception',
httpStatus: HttpStatus.BAD_REQUEST,
...options,
});
this.errorCode = 'INVALID_LOGIN_DATA_ERROR';
}
}
const logLevel = 'log' as LogLevel;
const exception = new TestException();

loggerSentryTransport.log(errorMessage, logLevel, exception);

expect(spyLogLevelMap).toBeCalledTimes(1);
expect(spyLogLevelMap).toHaveBeenCalledWith(logLevel);
expect(spyCaptureException).toBeCalledTimes(1);
expect(spyCaptureException).toHaveBeenCalledWith(exception, {
level: 'error',
extra: {
developerMessage: errorMessage,
errorCode: exception?.errorCode,
statusCode: exception?.httpStatus,
message: exception?.message,
safeMessage: exception?.safeMessage,
context: exception?.context,
},
});
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { Inject, Injectable, LogLevel } from '@nestjs/common';
import { HttpException, Inject, Injectable, LogLevel } from '@nestjs/common';
import * as Sentry from '@sentry/node';
import { LoggerTransportInterface } from '@concepta/nestjs-logger';
import { LoggerSentrySettingsInterface } from '../interfaces/logger-sentry-settings.interface';
import { LOGGER_SENTRY_MODULE_SETTINGS_TOKEN } from '../config/logger-sentry.config';
import { RuntimeException, mapHttpStatus } from '@concepta/nestjs-exception';
import { isObject } from 'class-validator';
import { LoggerSentryExtrasInterface } from '../interfaces/logger-sentry-extras.interface';

/**
* The transport that implements {@link LoggerTransportInterface}
Expand Down Expand Up @@ -46,7 +49,11 @@ export class LoggerSentryTransport implements LoggerTransportInterface {
* @param logLevel - Level of severity
* @param error - Error to log
*/
log(message: string, logLevel: LogLevel, error?: Error | string): void {
log(
message: string,
logLevel: LogLevel,
error?: Error | string | RuntimeException,
): void {
// map the internal log level to sentry log severity
const severity = this.settings.logLevelMap(logLevel);

Expand All @@ -55,12 +62,48 @@ export class LoggerSentryTransport implements LoggerTransportInterface {
// its an error, use error message
Sentry.captureException(error, {
level: severity,
// TODO: are we using this extras correctly?
extra: { developerMessage: message },
extra: {
developerMessage: message,
...this.getExtras(error),
},
});
} else {
// its a string, just send it
Sentry.captureMessage(message, severity);
}
}

private getExtras(
exception?: Error | string | RuntimeException | HttpException,
): LoggerSentryExtrasInterface {
const extras: LoggerSentryExtrasInterface = {};
if (exception instanceof HttpException) {
this.handleHttpException(exception, extras);
} else if (exception instanceof RuntimeException) {
this.handleRuntimeException(exception, extras);
}

return extras;
}

private handleHttpException(
exception: HttpException,
extras: LoggerSentryExtrasInterface,
): void {
const res = exception.getResponse();
extras.statusCode = exception.getStatus();
extras.errorCode = mapHttpStatus(extras.statusCode);
extras.message = isObject(res) && 'message' in res ? res.message : res;
}

private handleRuntimeException(
exception: RuntimeException,
extras: LoggerSentryExtrasInterface,
): void {
extras.errorCode = exception?.errorCode;
extras.statusCode = exception?.httpStatus;
extras.message = exception?.message;
extras.safeMessage = exception?.safeMessage;
extras.context = exception?.context;
}
}
1 change: 1 addition & 0 deletions packages/nestjs-org/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"@concepta/nestjs-common": "^5.0.0-alpha.4",
"@concepta/nestjs-crud": "^5.0.0-alpha.4",
"@concepta/nestjs-event": "^5.0.0-alpha.4",
"@concepta/nestjs-exception": "^5.0.0-alpha.4",
"@concepta/nestjs-typeorm-ext": "^5.0.0-alpha.4",
"@concepta/ts-common": "^5.0.0-alpha.4",
"@concepta/ts-core": "^5.0.0-alpha.4",
Expand Down
Loading
Loading