diff --git a/src/Core/API/API.Core/API.Core.csproj b/src/Core/API/API.Core/API.Core.csproj
index 29247b4..dbaf5e6 100644
--- a/src/Core/API/API.Core/API.Core.csproj
+++ b/src/Core/API/API.Core/API.Core.csproj
@@ -18,7 +18,8 @@
-
+
+
diff --git a/src/Core/API/API.Core/Controllers/AuthController.cs b/src/Core/API/API.Core/Controllers/AuthController.cs
index bccde9b..fb0ed13 100644
--- a/src/Core/API/API.Core/Controllers/AuthController.cs
+++ b/src/Core/API/API.Core/Controllers/AuthController.cs
@@ -44,13 +44,6 @@ namespace API.Core.Controllers
public async Task Login([FromBody] LoginRequest req)
{
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);
@@ -64,4 +57,4 @@ namespace API.Core.Controllers
});
}
}
-}
\ No newline at end of file
+}
diff --git a/src/Core/API/API.Core/Controllers/UserController.cs b/src/Core/API/API.Core/Controllers/UserController.cs
index fe98481..3cdd55a 100644
--- a/src/Core/API/API.Core/Controllers/UserController.cs
+++ b/src/Core/API/API.Core/Controllers/UserController.cs
@@ -19,7 +19,6 @@ namespace API.Core.Controllers
public async Task> GetById(Guid id)
{
var user = await userService.GetByIdAsync(id);
- if (user is null) return NotFound();
return Ok(user);
}
}
diff --git a/src/Core/API/API.Core/Dockerfile b/src/Core/API/API.Core/Dockerfile
index 3c57ada..abfa65a 100644
--- a/src/Core/API/API.Core/Dockerfile
+++ b/src/Core/API/API.Core/Dockerfile
@@ -9,7 +9,8 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
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.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
diff --git a/src/Core/API/API.Core/GlobalException.cs b/src/Core/API/API.Core/GlobalException.cs
new file mode 100644
index 0000000..9855e69
--- /dev/null
+++ b/src/Core/API/API.Core/GlobalException.cs
@@ -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 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;
+ }
+ }
+}
diff --git a/src/Core/API/API.Core/Middleware/ValidationExceptionHandlingMiddleware.cs b/src/Core/API/API.Core/Middleware/ValidationExceptionHandlingMiddleware.cs
deleted file mode 100644
index a631cd0..0000000
--- a/src/Core/API/API.Core/Middleware/ValidationExceptionHandlingMiddleware.cs
+++ /dev/null
@@ -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));
- }
-}
diff --git a/src/Core/API/API.Core/Program.cs b/src/Core/API/API.Core/Program.cs
index 82f1463..6c93d98 100644
--- a/src/Core/API/API.Core/Program.cs
+++ b/src/Core/API/API.Core/Program.cs
@@ -1,3 +1,5 @@
+using API.Core;
+using Domain.Exceptions;
using FluentValidation;
using FluentValidation.AspNetCore;
using Infrastructure.Jwt;
@@ -6,34 +8,19 @@ using Infrastructure.Repository.Auth;
using Infrastructure.Repository.Sql;
using Infrastructure.Repository.UserAccount;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Filters;
using Service.Auth.Auth;
using Service.UserManagement.User;
-
+using API.Core.Contracts.Common;
var builder = WebApplication.CreateBuilder(args);
-builder.Services.AddControllers()
- .ConfigureApiBehaviorOptions(options =>
- {
- options.InvalidModelStateResponseFactory = context =>
- {
- var errors = context.ModelState.Values
- .SelectMany(v => v.Errors)
- .Select(e => e.ErrorMessage)
- .ToList();
+// Global Exception Filter
+builder.Services.AddControllers(options =>
+{
+ options.Filters.Add();
+});
- var message = errors.Count == 1
- ? errors[0]
- : string.Join(" ", errors);
-
- var response = new
- {
- message
- };
-
- return new BadRequestObjectResult(response);
- };
- });
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddOpenApi();
@@ -67,6 +54,8 @@ builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+// Register the exception filter
+builder.Services.AddScoped();
var app = builder.Build();
@@ -90,6 +79,3 @@ lifetime.ApplicationStopping.Register(() =>
});
app.Run();
-
-// Make Program class accessible to test projects
-public partial class Program { }
diff --git a/src/Core/API/API.Specs/Dockerfile b/src/Core/API/API.Specs/Dockerfile
index 38a12d2..a22ad28 100644
--- a/src/Core/API/API.Specs/Dockerfile
+++ b/src/Core/API/API.Specs/Dockerfile
@@ -3,7 +3,8 @@ ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
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.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
diff --git a/src/Core/API/API.Specs/Features/Registration.feature b/src/Core/API/API.Specs/Features/Registration.feature
index cf6d3d0..0ff53c3 100644
--- a/src/Core/API/API.Specs/Features/Registration.feature
+++ b/src/Core/API/API.Specs/Features/Registration.feature
@@ -12,25 +12,19 @@ Feature: User Registration
And the response JSON should have "message" equal "User registered successfully."
And the response JSON should have an access token
- @Ignore
Scenario: Registration fails with existing username
Given the API is running
- And I have an existing account with username "existinguser"
When I submit a registration request with values:
- | Username | FirstName | LastName | Email | DateOfBirth | Password |
- | existinguser | Existing | User | existing@example.com | 1990-01-01 | Password1! |
+ | Username | FirstName | LastName | Email | DateOfBirth | Password |
+ | test.user | Test | User | example@example.com | 2001-11-11 | Password1! |
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
Given the API is running
- And I have an existing account with email "existing@example.com"
When I submit a registration request with values:
- | Username | FirstName | LastName | Email | DateOfBirth | Password |
- | newuser | New | User | existing@example.com | 1990-01-01 | Password1! |
+ | Username | FirstName | LastName | Email | DateOfBirth | Password |
+ | newuser | New | User | test.user@thebiergarten.app | 1990-01-01 | Password1! |
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
Given the API is running
diff --git a/src/Core/API/API.Specs/Steps/AuthSteps.cs b/src/Core/API/API.Specs/Steps/AuthSteps.cs
index b2e6fdb..adf145f 100644
--- a/src/Core/API/API.Specs/Steps/AuthSteps.cs
+++ b/src/Core/API/API.Specs/Steps/AuthSteps.cs
@@ -201,18 +201,6 @@ public class AuthSteps(ScenarioContext scenario)
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")]
public async Task WhenISubmitARegistrationRequestUsingAGetRequest()
{
diff --git a/src/Core/Core.slnx b/src/Core/Core.slnx
index c48bc6c..da5c14a 100644
--- a/src/Core/Core.slnx
+++ b/src/Core/Core.slnx
@@ -8,7 +8,8 @@
-
+
+
diff --git a/src/Core/Database/Database.Seed/Database.Seed.csproj b/src/Core/Database/Database.Seed/Database.Seed.csproj
index 9a44208..43384cb 100644
--- a/src/Core/Database/Database.Seed/Database.Seed.csproj
+++ b/src/Core/Database/Database.Seed/Database.Seed.csproj
@@ -18,7 +18,7 @@
-
+
diff --git a/src/Core/Database/Database.Seed/UserSeeder.cs b/src/Core/Database/Database.Seed/UserSeeder.cs
index b7ae4ba..d47fe65 100644
--- a/src/Core/Database/Database.Seed/UserSeeder.cs
+++ b/src/Core/Database/Database.Seed/UserSeeder.cs
@@ -124,6 +124,7 @@ internal class UserSeeder : ISeeder
int createdCredentials = 0;
int createdVerifications = 0;
+ // create a known user for testing purposes
{
const string firstName = "Test";
const string lastName = "User";
@@ -264,4 +265,4 @@ internal class UserSeeder : ISeeder
int offsetDays = random.Next(0, 365);
return baseDate.AddDays(-offsetDays);
}
-}
\ No newline at end of file
+}
diff --git a/src/Core/Domain/Domain.csproj b/src/Core/Domain.Entities/Domain.Entities.csproj
similarity index 100%
rename from src/Core/Domain/Domain.csproj
rename to src/Core/Domain.Entities/Domain.Entities.csproj
diff --git a/src/Core/Domain/Entities/UserAccount.cs b/src/Core/Domain.Entities/Entities/UserAccount.cs
similarity index 100%
rename from src/Core/Domain/Entities/UserAccount.cs
rename to src/Core/Domain.Entities/Entities/UserAccount.cs
diff --git a/src/Core/Domain/Entities/UserCredential.cs b/src/Core/Domain.Entities/Entities/UserCredential.cs
similarity index 100%
rename from src/Core/Domain/Entities/UserCredential.cs
rename to src/Core/Domain.Entities/Entities/UserCredential.cs
diff --git a/src/Core/Domain/Entities/UserVerification.cs b/src/Core/Domain.Entities/Entities/UserVerification.cs
similarity index 100%
rename from src/Core/Domain/Entities/UserVerification.cs
rename to src/Core/Domain.Entities/Entities/UserVerification.cs
diff --git a/src/Core/Domain.Exceptions/Domain.Exceptions.csproj b/src/Core/Domain.Exceptions/Domain.Exceptions.csproj
new file mode 100644
index 0000000..237d661
--- /dev/null
+++ b/src/Core/Domain.Exceptions/Domain.Exceptions.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/src/Core/Domain.Exceptions/Exceptions.cs b/src/Core/Domain.Exceptions/Exceptions.cs
new file mode 100644
index 0000000..209880c
--- /dev/null
+++ b/src/Core/Domain.Exceptions/Exceptions.cs
@@ -0,0 +1,33 @@
+namespace Domain.Exceptions;
+
+///
+/// Exception thrown when a resource conflict occurs (e.g., duplicate username, email already in use).
+/// Maps to HTTP 409 Conflict.
+///
+public class ConflictException(string message) : Exception(message);
+
+///
+/// Exception thrown when a requested resource is not found.
+/// Maps to HTTP 404 Not Found.
+///
+public class NotFoundException(string message) : Exception(message);
+
+// Domain.Exceptions/UnauthorizedException.cs
+
+///
+/// Exception thrown when authentication fails or is required.
+/// Maps to HTTP 401 Unauthorized.
+///
+public class UnauthorizedException(string message) : Exception(message);
+
+///
+/// Exception thrown when a user is authenticated but lacks permission to access a resource.
+/// Maps to HTTP 403 Forbidden.
+///
+public class ForbiddenException(string message) : Exception(message);
+
+///
+/// Exception thrown when business rule validation fails (distinct from FluentValidation).
+/// Maps to HTTP 400 Bad Request.
+///
+public class ValidationException(string message) : Exception(message);
\ No newline at end of file
diff --git a/src/Core/Infrastructure/Infrastructure.Repository.Tests/Dockerfile b/src/Core/Infrastructure/Infrastructure.Repository.Tests/Dockerfile
index 5de992c..8f6eea8 100644
--- a/src/Core/Infrastructure/Infrastructure.Repository.Tests/Dockerfile
+++ b/src/Core/Infrastructure/Infrastructure.Repository.Tests/Dockerfile
@@ -1,7 +1,8 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
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.Tests/Infrastructure.Repository.Tests.csproj", "Infrastructure/Infrastructure.Repository.Tests/"]
RUN dotnet restore "Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj"
diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj b/src/Core/Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj
index 178bda8..c38e1f4 100644
--- a/src/Core/Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj
+++ b/src/Core/Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj
@@ -18,6 +18,6 @@
/>
-
+
diff --git a/src/Core/Service/Service.Auth/Auth/ILoginService.cs b/src/Core/Service/Service.Auth/Auth/ILoginService.cs
index 667a30a..9f9e022 100644
--- a/src/Core/Service/Service.Auth/Auth/ILoginService.cs
+++ b/src/Core/Service/Service.Auth/Auth/ILoginService.cs
@@ -5,5 +5,5 @@ namespace Service.Auth.Auth;
public interface ILoginService
{
- Task LoginAsync(string username, string password);
+ Task LoginAsync(string username, string password);
}
diff --git a/src/Core/Service/Service.Auth/Auth/LoginService.cs b/src/Core/Service/Service.Auth/Auth/LoginService.cs
index f931ce2..754a5a4 100644
--- a/src/Core/Service/Service.Auth/Auth/LoginService.cs
+++ b/src/Core/Service/Service.Auth/Auth/LoginService.cs
@@ -1,5 +1,6 @@
using System.Threading.Tasks;
using Domain.Entities;
+using Domain.Exceptions;
using Infrastructure.PasswordHashing;
using Infrastructure.Repository.Auth;
@@ -11,18 +12,24 @@ public class LoginService(
) : ILoginService
{
- public async Task LoginAsync(string username, string password)
+ public async Task LoginAsync(string username, string password)
{
// Attempt lookup by username
var user = await authRepo.GetUserByUsernameAsync(username);
// 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
var activeCred = await authRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId);
- if (activeCred is null) return null;
- return !passwordInfrastructure.Verify(password, activeCred.Hash) ? null : user;
+ if (activeCred is null)
+ throw new UnauthorizedException("Invalid username or password.");
+
+ if (!passwordInfrastructure.Verify(password, activeCred.Hash))
+ throw new UnauthorizedException("Invalid username or password.");
+
+ return user;
}
}
diff --git a/src/Core/Service/Service.Auth/Auth/RegisterService.cs b/src/Core/Service/Service.Auth/Auth/RegisterService.cs
index 5b1493b..c136c16 100644
--- a/src/Core/Service/Service.Auth/Auth/RegisterService.cs
+++ b/src/Core/Service/Service.Auth/Auth/RegisterService.cs
@@ -1,5 +1,6 @@
using System.Threading.Tasks;
using Domain.Entities;
+using Domain.Exceptions;
using Infrastructure.PasswordHashing;
using Infrastructure.Repository.Auth;
@@ -13,12 +14,16 @@ public class RegisterService(
public async Task RegisterAsync(UserAccount userAccount, string password)
{
// Check if user already exists
- var user = await authRepo.GetUserByUsernameAsync(userAccount.Username);
- if (user is not null)
+ var existingUsername = await authRepo.GetUserByUsernameAsync(userAccount.Username);
+ var existingEmail = await authRepo.GetUserByEmailAsync(userAccount.Email);
+
+ if (existingUsername != null || existingEmail != null)
{
- return null!;
+ throw new ConflictException("Username or email already exists");
}
+
+
// password hashing
var hashed = passwordInfrastructure.Hash(password);
@@ -32,5 +37,5 @@ public class RegisterService(
hashed);
}
-
+
}
diff --git a/src/Core/Service/Service.Auth/Service.Auth.csproj b/src/Core/Service/Service.Auth/Service.Auth.csproj
index 48566dc..6d9133a 100644
--- a/src/Core/Service/Service.Auth/Service.Auth.csproj
+++ b/src/Core/Service/Service.Auth/Service.Auth.csproj
@@ -10,8 +10,10 @@
-
-
+
+
+
diff --git a/src/Core/Service/Service.UserManagement/Service.UserManagement.csproj b/src/Core/Service/Service.UserManagement/Service.UserManagement.csproj
index 776d954..b26eb31 100644
--- a/src/Core/Service/Service.UserManagement/Service.UserManagement.csproj
+++ b/src/Core/Service/Service.UserManagement/Service.UserManagement.csproj
@@ -7,7 +7,9 @@
-
+
+
diff --git a/src/Core/Service/Service.UserManagement/User/IUserService.cs b/src/Core/Service/Service.UserManagement/User/IUserService.cs
index 71dcfac..ab66525 100644
--- a/src/Core/Service/Service.UserManagement/User/IUserService.cs
+++ b/src/Core/Service/Service.UserManagement/User/IUserService.cs
@@ -5,7 +5,7 @@ namespace Service.UserManagement.User;
public interface IUserService
{
Task> GetAllAsync(int? limit = null, int? offset = null);
- Task GetByIdAsync(Guid id);
+ Task GetByIdAsync(Guid id);
Task UpdateAsync(UserAccount userAccount);
}
diff --git a/src/Core/Service/Service.UserManagement/User/UserService.cs b/src/Core/Service/Service.UserManagement/User/UserService.cs
index 7391ec0..43a7816 100644
--- a/src/Core/Service/Service.UserManagement/User/UserService.cs
+++ b/src/Core/Service/Service.UserManagement/User/UserService.cs
@@ -1,4 +1,5 @@
using Domain.Entities;
+using Domain.Exceptions;
using Infrastructure.Repository.UserAccount;
namespace Service.UserManagement.User;
@@ -10,9 +11,12 @@ public class UserService(IUserAccountRepository repository) : IUserService
return await repository.GetAllAsync(limit, offset);
}
- public async Task GetByIdAsync(Guid id)
+ public async Task 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)