Update exception handling (#146)

This commit is contained in:
Aaron Po
2026-02-12 21:06:07 -05:00
committed by GitHub
parent 584fe6282f
commit 7129e5679e
28 changed files with 191 additions and 126 deletions

View File

@@ -18,7 +18,8 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.csproj" /> <ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" /> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" /> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
<ProjectReference Include="..\..\Service\Service.Auth\Service.Auth.csproj" /> <ProjectReference Include="..\..\Service\Service.Auth\Service.Auth.csproj" />

View File

@@ -44,13 +44,6 @@ namespace API.Core.Controllers
public async Task<ActionResult> Login([FromBody] LoginRequest req) public async Task<ActionResult> Login([FromBody] LoginRequest req)
{ {
var userAccount = await login.LoginAsync(req.Username, req.Password); var userAccount = await login.LoginAsync(req.Username, req.Password);
if (userAccount is null)
{
return Unauthorized(new ResponseBody
{
Message = "Invalid username or password."
});
}
UserDTO dto = new(userAccount.UserAccountId, userAccount.Username); UserDTO dto = new(userAccount.UserAccountId, userAccount.Username);

View File

@@ -19,7 +19,6 @@ namespace API.Core.Controllers
public async Task<ActionResult<UserAccount>> GetById(Guid id) public async Task<ActionResult<UserAccount>> GetById(Guid id)
{ {
var user = await userService.GetByIdAsync(id); var user = await userService.GetByIdAsync(id);
if (user is null) return NotFound();
return Ok(user); return Ok(user);
} }
} }

View File

@@ -9,7 +9,8 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release ARG BUILD_CONFIGURATION=Release
WORKDIR /src WORKDIR /src
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"] COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
COPY ["Domain/Domain.csproj", "Domain/"] COPY ["Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
COPY ["Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"] COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"] COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"] COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]

View File

@@ -0,0 +1,84 @@
// API.Core/Filters/GlobalExceptionFilter.cs
using API.Core.Contracts.Common;
using Domain.Exceptions;
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace API.Core;
public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger) : IExceptionFilter
{
public void OnException(ExceptionContext context)
{
logger.LogError(context.Exception, "Unhandled exception occurred");
switch (context.Exception)
{
case FluentValidation.ValidationException fluentValidationException:
var errors = fluentValidationException.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(
g => g.Key,
g => g.Select(e => e.ErrorMessage).ToArray()
);
context.Result = new BadRequestObjectResult(new
{
message = "Validation failed",
errors
});
context.ExceptionHandled = true;
break;
case ConflictException ex:
context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
{
StatusCode = 409
};
context.ExceptionHandled = true;
break;
case NotFoundException ex:
context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
{
StatusCode = 404
};
context.ExceptionHandled = true;
break;
case UnauthorizedException ex:
context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
{
StatusCode = 401
};
context.ExceptionHandled = true;
break;
case ForbiddenException ex:
context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
{
StatusCode = 403
};
context.ExceptionHandled = true;
break;
case Domain.Exceptions.ValidationException ex:
context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
{
StatusCode = 400
};
context.ExceptionHandled = true;
break;
default:
context.Result = new ObjectResult(new ResponseBody { Message = "An unexpected error occurred" })
{
StatusCode = 500
};
context.ExceptionHandled = true;
break;
}
}
}

View File

@@ -1,47 +0,0 @@
using System.Net;
using System.Text.Json;
using API.Core.Contracts.Common;
using FluentValidation;
namespace API.Core.Middleware;
public class ValidationExceptionHandlingMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context)
{
try
{
await next(context);
}
catch (ValidationException ex)
{
await HandleValidationExceptionAsync(context, ex);
}
}
private static Task HandleValidationExceptionAsync(HttpContext context, ValidationException exception)
{
context.Response.ContentType = "application/json";
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
var errors = exception.Errors
.Select(e => e.ErrorMessage)
.ToList();
var message = errors.Count == 1
? errors[0]
: "Validation failed. " + string.Join(" ", errors);
var response = new ResponseBody
{
Message = message
};
var jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
return context.Response.WriteAsync(JsonSerializer.Serialize(response, jsonOptions));
}
}

View File

@@ -1,3 +1,5 @@
using API.Core;
using Domain.Exceptions;
using FluentValidation; using FluentValidation;
using FluentValidation.AspNetCore; using FluentValidation.AspNetCore;
using Infrastructure.Jwt; using Infrastructure.Jwt;
@@ -6,34 +8,19 @@ using Infrastructure.Repository.Auth;
using Infrastructure.Repository.Sql; using Infrastructure.Repository.Sql;
using Infrastructure.Repository.UserAccount; using Infrastructure.Repository.UserAccount;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Service.Auth.Auth; using Service.Auth.Auth;
using Service.UserManagement.User; using Service.UserManagement.User;
using API.Core.Contracts.Common;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers() // Global Exception Filter
.ConfigureApiBehaviorOptions(options => builder.Services.AddControllers(options =>
{ {
options.InvalidModelStateResponseFactory = context => options.Filters.Add<GlobalExceptionFilter>();
{ });
var errors = context.ModelState.Values
.SelectMany(v => v.Errors)
.Select(e => e.ErrorMessage)
.ToList();
var message = errors.Count == 1
? errors[0]
: string.Join(" ", errors);
var response = new
{
message
};
return new BadRequestObjectResult(response);
};
});
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen();
builder.Services.AddOpenApi(); builder.Services.AddOpenApi();
@@ -67,6 +54,8 @@ builder.Services.AddScoped<IRegisterService, RegisterService>();
builder.Services.AddScoped<ITokenInfrastructure, JwtInfrastructure>(); builder.Services.AddScoped<ITokenInfrastructure, JwtInfrastructure>();
builder.Services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>(); builder.Services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>();
// Register the exception filter
builder.Services.AddScoped<GlobalExceptionFilter>();
var app = builder.Build(); var app = builder.Build();
@@ -90,6 +79,3 @@ lifetime.ApplicationStopping.Register(() =>
}); });
app.Run(); app.Run();
// Make Program class accessible to test projects
public partial class Program { }

View File

@@ -3,7 +3,8 @@ ARG BUILD_CONFIGURATION=Release
WORKDIR /src WORKDIR /src
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"] COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"] COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"]
COPY ["Domain/Domain.csproj", "Domain/"] COPY ["Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
COPY ["Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"] COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"] COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"] COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]

View File

@@ -12,25 +12,19 @@ Feature: User Registration
And the response JSON should have "message" equal "User registered successfully." And the response JSON should have "message" equal "User registered successfully."
And the response JSON should have an access token And the response JSON should have an access token
@Ignore
Scenario: Registration fails with existing username Scenario: Registration fails with existing username
Given the API is running Given the API is running
And I have an existing account with username "existinguser"
When I submit a registration request with values: When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password | | Username | FirstName | LastName | Email | DateOfBirth | Password |
| existinguser | Existing | User | existing@example.com | 1990-01-01 | Password1! | | test.user | Test | User | example@example.com | 2001-11-11 | Password1! |
Then the response has HTTP status 409 Then the response has HTTP status 409
And the response JSON should have "message" equal "Username already exists."
@Ignore
Scenario: Registration fails with existing email Scenario: Registration fails with existing email
Given the API is running Given the API is running
And I have an existing account with email "existing@example.com"
When I submit a registration request with values: When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password | | Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | existing@example.com | 1990-01-01 | Password1! | | newuser | New | User | test.user@thebiergarten.app | 1990-01-01 | Password1! |
Then the response has HTTP status 409 Then the response has HTTP status 409
And the response JSON should have "message" equal "Email already in use."
Scenario: Registration fails with missing required fields Scenario: Registration fails with missing required fields
Given the API is running Given the API is running

View File

@@ -201,18 +201,6 @@ public class AuthSteps(ScenarioContext scenario)
scenario[ResponseBodyKey] = responseBody; scenario[ResponseBodyKey] = responseBody;
} }
[Given("I have an existing account with username {string}")]
public void GivenIHaveAnExistingAccountWithUsername(string username)
{
}
[Given("I have an existing account with email {string}")]
public void GivenIHaveAnExistingAccountWithEmail(string email)
{
}
[When("I submit a registration request using a GET request")] [When("I submit a registration request using a GET request")]
public async Task WhenISubmitARegistrationRequestUsingAGetRequest() public async Task WhenISubmitARegistrationRequestUsingAGetRequest()
{ {

View File

@@ -8,7 +8,8 @@
<Project Path="Database/Database.Seed/Database.Seed.csproj" /> <Project Path="Database/Database.Seed/Database.Seed.csproj" />
</Folder> </Folder>
<Folder Name="/Domain/"> <Folder Name="/Domain/">
<Project Path="Domain/Domain.csproj" /> <Project Path="Domain.Entities\Domain.Entities.csproj" />
<Project Path="Domain.Exceptions/Domain.Exceptions.csproj" />
</Folder> </Folder>
<Folder Name="/Infrastructure/"> <Folder Name="/Infrastructure/">
<Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj" /> <Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj" />

View File

@@ -18,7 +18,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.csproj" /> <ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" /> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -124,6 +124,7 @@ internal class UserSeeder : ISeeder
int createdCredentials = 0; int createdCredentials = 0;
int createdVerifications = 0; int createdVerifications = 0;
// create a known user for testing purposes
{ {
const string firstName = "Test"; const string firstName = "Test";
const string lastName = "User"; const string lastName = "User";

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,33 @@
namespace Domain.Exceptions;
/// <summary>
/// Exception thrown when a resource conflict occurs (e.g., duplicate username, email already in use).
/// Maps to HTTP 409 Conflict.
/// </summary>
public class ConflictException(string message) : Exception(message);
/// <summary>
/// Exception thrown when a requested resource is not found.
/// Maps to HTTP 404 Not Found.
/// </summary>
public class NotFoundException(string message) : Exception(message);
// Domain.Exceptions/UnauthorizedException.cs
/// <summary>
/// Exception thrown when authentication fails or is required.
/// Maps to HTTP 401 Unauthorized.
/// </summary>
public class UnauthorizedException(string message) : Exception(message);
/// <summary>
/// Exception thrown when a user is authenticated but lacks permission to access a resource.
/// Maps to HTTP 403 Forbidden.
/// </summary>
public class ForbiddenException(string message) : Exception(message);
/// <summary>
/// Exception thrown when business rule validation fails (distinct from FluentValidation).
/// Maps to HTTP 400 Bad Request.
/// </summary>
public class ValidationException(string message) : Exception(message);

View File

@@ -1,7 +1,8 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release ARG BUILD_CONFIGURATION=Release
WORKDIR /src WORKDIR /src
COPY ["Domain/Domain.csproj", "Domain/"] COPY ["Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
COPY ["Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"] COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
COPY ["Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj", "Infrastructure/Infrastructure.Repository.Tests/"] COPY ["Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj", "Infrastructure/Infrastructure.Repository.Tests/"]
RUN dotnet restore "Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj" RUN dotnet restore "Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj"

View File

@@ -18,6 +18,6 @@
/> />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.csproj" /> <ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -5,5 +5,5 @@ namespace Service.Auth.Auth;
public interface ILoginService public interface ILoginService
{ {
Task<UserAccount?> LoginAsync(string username, string password); Task<UserAccount> LoginAsync(string username, string password);
} }

View File

@@ -1,5 +1,6 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using Domain.Entities; using Domain.Entities;
using Domain.Exceptions;
using Infrastructure.PasswordHashing; using Infrastructure.PasswordHashing;
using Infrastructure.Repository.Auth; using Infrastructure.Repository.Auth;
@@ -11,18 +12,24 @@ public class LoginService(
) : ILoginService ) : ILoginService
{ {
public async Task<UserAccount?> LoginAsync(string username, string password) public async Task<UserAccount> LoginAsync(string username, string password)
{ {
// Attempt lookup by username // Attempt lookup by username
var user = await authRepo.GetUserByUsernameAsync(username); var user = await authRepo.GetUserByUsernameAsync(username);
// the user was not found // the user was not found
if (user is null) return null; if (user is null)
throw new UnauthorizedException("Invalid username or password.");
// @todo handle expired passwords // @todo handle expired passwords
var activeCred = await authRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId); var activeCred = await authRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId);
if (activeCred is null) return null; if (activeCred is null)
return !passwordInfrastructure.Verify(password, activeCred.Hash) ? null : user; throw new UnauthorizedException("Invalid username or password.");
if (!passwordInfrastructure.Verify(password, activeCred.Hash))
throw new UnauthorizedException("Invalid username or password.");
return user;
} }
} }

View File

@@ -1,5 +1,6 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using Domain.Entities; using Domain.Entities;
using Domain.Exceptions;
using Infrastructure.PasswordHashing; using Infrastructure.PasswordHashing;
using Infrastructure.Repository.Auth; using Infrastructure.Repository.Auth;
@@ -13,12 +14,16 @@ public class RegisterService(
public async Task<UserAccount> RegisterAsync(UserAccount userAccount, string password) public async Task<UserAccount> RegisterAsync(UserAccount userAccount, string password)
{ {
// Check if user already exists // Check if user already exists
var user = await authRepo.GetUserByUsernameAsync(userAccount.Username); var existingUsername = await authRepo.GetUserByUsernameAsync(userAccount.Username);
if (user is not null) var existingEmail = await authRepo.GetUserByEmailAsync(userAccount.Email);
if (existingUsername != null || existingEmail != null)
{ {
return null!; throw new ConflictException("Username or email already exists");
} }
// password hashing // password hashing
var hashed = passwordInfrastructure.Hash(password); var hashed = passwordInfrastructure.Hash(password);

View File

@@ -10,8 +10,10 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.csproj" /> <ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" /> <ProjectReference Include="..\..\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference
Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
<ProjectReference <ProjectReference
Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj" /> Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj" />
</ItemGroup> </ItemGroup>

View File

@@ -7,7 +7,9 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" /> <ProjectReference Include="..\..\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference
Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -5,7 +5,7 @@ namespace Service.UserManagement.User;
public interface IUserService public interface IUserService
{ {
Task<IEnumerable<UserAccount>> GetAllAsync(int? limit = null, int? offset = null); Task<IEnumerable<UserAccount>> GetAllAsync(int? limit = null, int? offset = null);
Task<UserAccount?> GetByIdAsync(Guid id); Task<UserAccount> GetByIdAsync(Guid id);
Task UpdateAsync(UserAccount userAccount); Task UpdateAsync(UserAccount userAccount);
} }

View File

@@ -1,4 +1,5 @@
using Domain.Entities; using Domain.Entities;
using Domain.Exceptions;
using Infrastructure.Repository.UserAccount; using Infrastructure.Repository.UserAccount;
namespace Service.UserManagement.User; namespace Service.UserManagement.User;
@@ -10,9 +11,12 @@ public class UserService(IUserAccountRepository repository) : IUserService
return await repository.GetAllAsync(limit, offset); return await repository.GetAllAsync(limit, offset);
} }
public async Task<UserAccount?> GetByIdAsync(Guid id) public async Task<UserAccount> GetByIdAsync(Guid id)
{ {
return await repository.GetByIdAsync(id); var user = await repository.GetByIdAsync(id);
if (user is null)
throw new NotFoundException($"User with ID {id} not found");
return user;
} }
public async Task UpdateAsync(UserAccount userAccount) public async Task UpdateAsync(UserAccount userAccount)