diff --git a/src/Core/API/API.Core/Controllers/AuthController.cs b/src/Core/API/API.Core/Controllers/AuthController.cs index fb0ed13..7243141 100644 --- a/src/Core/API/API.Core/Controllers/AuthController.cs +++ b/src/Core/API/API.Core/Controllers/AuthController.cs @@ -3,7 +3,7 @@ using API.Core.Contracts.Common; using Domain.Entities; using Infrastructure.Jwt; using Microsoft.AspNetCore.Mvc; -using Service.Auth.Auth; +using Service.Auth; namespace API.Core.Controllers { diff --git a/src/Core/API/API.Core/Program.cs b/src/Core/API/API.Core/Program.cs index 68866b5..ff70e6d 100644 --- a/src/Core/API/API.Core/Program.cs +++ b/src/Core/API/API.Core/Program.cs @@ -9,12 +9,12 @@ 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; using Infrastructure.Email; using Infrastructure.Email.Templates; using Infrastructure.Email.Templates.Rendering; +using Service.Auth; var builder = WebApplication.CreateBuilder(args); diff --git a/src/Core/API/API.Specs/Dockerfile b/src/Core/API/API.Specs/Dockerfile index 887e7d7..f82f51b 100644 --- a/src/Core/API/API.Specs/Dockerfile +++ b/src/Core/API/API.Specs/Dockerfile @@ -20,6 +20,6 @@ FROM build AS final ARG BUILD_CONFIGURATION=Release WORKDIR /src RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* -RUN mkdir -p /app/test-results +RUN mkdir -p /app/test-results/api-specs WORKDIR /src/API/API.Specs -ENTRYPOINT ["dotnet", "test", "API.Specs.csproj", "-c", "Release", "--logger", "trx;LogFileName=/app/test-results/test-results.trx"] +ENTRYPOINT ["dotnet", "test", "API.Specs.csproj", "-c", "Release", "--logger", "trx;LogFileName=/app/test-results/api-specs/results.trx"] diff --git a/src/Core/Core.slnx b/src/Core/Core.slnx index 19d646f..a088dcd 100644 --- a/src/Core/Core.slnx +++ b/src/Core/Core.slnx @@ -1,26 +1,27 @@ - - + + - - + + - - + + - - - - - - + + + + + + - - + + + diff --git a/src/Core/Database/Database.Seed/Database.Seed.csproj b/src/Core/Database/Database.Seed/Database.Seed.csproj index 43384cb..88bebda 100644 --- a/src/Core/Database/Database.Seed/Database.Seed.csproj +++ b/src/Core/Database/Database.Seed/Database.Seed.csproj @@ -13,7 +13,7 @@ Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" /> - + diff --git a/src/Core/Infrastructure/Infrastructure.Repository.Tests/Dockerfile b/src/Core/Infrastructure/Infrastructure.Repository.Tests/Dockerfile index 8f6eea8..f06bb9e 100644 --- a/src/Core/Infrastructure/Infrastructure.Repository.Tests/Dockerfile +++ b/src/Core/Infrastructure/Infrastructure.Repository.Tests/Dockerfile @@ -11,6 +11,6 @@ WORKDIR "/src/Infrastructure/Infrastructure.Repository.Tests" RUN dotnet build "./Infrastructure.Repository.Tests.csproj" -c $BUILD_CONFIGURATION -o /app/build FROM build AS final -RUN mkdir -p /app/test-results +RUN mkdir -p /app/test-results/repository-tests WORKDIR /src/Infrastructure/Infrastructure.Repository.Tests -ENTRYPOINT ["dotnet", "test", "./Infrastructure.Repository.Tests.csproj", "-c", "Release", "--logger", "trx;LogFileName=/app/test-results/repository-tests.trx"] +ENTRYPOINT ["dotnet", "test", "./Infrastructure.Repository.Tests.csproj", "-c", "Release", "--logger", "trx;LogFileName=/app/test-results/repository-tests/results.trx"] diff --git a/src/Core/Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj b/src/Core/Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj index a71f674..55a49a2 100644 --- a/src/Core/Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj +++ b/src/Core/Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj @@ -6,28 +6,14 @@ false Infrastructure.Repository.Tests - - - + - - - - + diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs b/src/Core/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs index 4672852..83b26d7 100644 --- a/src/Core/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs +++ b/src/Core/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs @@ -88,9 +88,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) AddParameter(command, "@UserAccountId", userAccountId); await using var reader = await command.ExecuteReaderAsync(); - return await reader.ReadAsync() - ? MapToCredentialEntity(reader) - : null; + return await reader.ReadAsync() ? MapToCredentialEntity(reader) : null; } public async Task RotateCredentialAsync( @@ -118,9 +116,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) { return new Domain.Entities.UserAccount { - UserAccountId = reader.GetGuid( - reader.GetOrdinal("UserAccountId") - ), + UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")), Username = reader.GetString(reader.GetOrdinal("Username")), FirstName = reader.GetString(reader.GetOrdinal("FirstName")), LastName = reader.GetString(reader.GetOrdinal("LastName")), @@ -129,9 +125,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) UpdatedAt = reader.IsDBNull(reader.GetOrdinal("UpdatedAt")) ? null : reader.GetDateTime(reader.GetOrdinal("UpdatedAt")), - DateOfBirth = reader.GetDateTime( - reader.GetOrdinal("DateOfBirth") - ), + DateOfBirth = reader.GetDateTime(reader.GetOrdinal("DateOfBirth")), Timer = reader.IsDBNull(reader.GetOrdinal("Timer")) ? null : (byte[])reader["Timer"], @@ -148,9 +142,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) UserCredentialId = reader.GetGuid( reader.GetOrdinal("UserCredentialId") ), - UserAccountId = reader.GetGuid( - reader.GetOrdinal("UserAccountId") - ), + UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")), Hash = reader.GetString(reader.GetOrdinal("Hash")), CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")), }; @@ -192,4 +184,4 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) p.Value = value ?? DBNull.Value; command.Parameters.Add(p); } -} \ No newline at end of file +} diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Auth/IAuthRepository.cs b/src/Core/Infrastructure/Infrastructure.Repository/Auth/IAuthRepository.cs index 4a57360..f8472c1 100644 --- a/src/Core/Infrastructure/Infrastructure.Repository/Auth/IAuthRepository.cs +++ b/src/Core/Infrastructure/Infrastructure.Repository/Auth/IAuthRepository.cs @@ -33,9 +33,7 @@ public interface IAuthRepository /// /// Email address to search for /// UserAccount if found, null otherwise - Task GetUserByEmailAsync( - string email - ); + Task GetUserByEmailAsync(string email); /// /// Retrieves a user account by username (typically used for login). @@ -43,9 +41,7 @@ public interface IAuthRepository /// /// Username to search for /// UserAccount if found, null otherwise - Task GetUserByUsernameAsync( - string username - ); + Task GetUserByUsernameAsync(string username); /// /// Retrieves the active (non-revoked) credential for a user account. @@ -64,4 +60,4 @@ public interface IAuthRepository /// ID of the user account /// New hashed password Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash); -} \ No newline at end of file +} diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj b/src/Core/Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj index c38e1f4..4748809 100644 --- a/src/Core/Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj +++ b/src/Core/Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj @@ -6,7 +6,7 @@ Infrastructure.Repository - + diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Repository.cs b/src/Core/Infrastructure/Infrastructure.Repository/Repository.cs index 30672d9..2419f56 100644 --- a/src/Core/Infrastructure/Infrastructure.Repository/Repository.cs +++ b/src/Core/Infrastructure/Infrastructure.Repository/Repository.cs @@ -14,4 +14,4 @@ public abstract class Repository(ISqlConnectionFactory connectionFactory) } protected abstract T MapToEntity(DbDataReader reader); -} \ No newline at end of file +} diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Sql/DefaultSqlConnectionFactory.cs b/src/Core/Infrastructure/Infrastructure.Repository/Sql/DefaultSqlConnectionFactory.cs index 8d5bf62..dcd3652 100644 --- a/src/Core/Infrastructure/Infrastructure.Repository/Sql/DefaultSqlConnectionFactory.cs +++ b/src/Core/Infrastructure/Infrastructure.Repository/Sql/DefaultSqlConnectionFactory.cs @@ -46,4 +46,4 @@ public class DefaultSqlConnectionFactory(IConfiguration configuration) { return new SqlConnection(_connectionString); } -} \ No newline at end of file +} diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Sql/ISqlConnectionFactory.cs b/src/Core/Infrastructure/Infrastructure.Repository/Sql/ISqlConnectionFactory.cs index 40a6eed..2a7ac18 100644 --- a/src/Core/Infrastructure/Infrastructure.Repository/Sql/ISqlConnectionFactory.cs +++ b/src/Core/Infrastructure/Infrastructure.Repository/Sql/ISqlConnectionFactory.cs @@ -5,4 +5,4 @@ namespace Infrastructure.Repository.Sql; public interface ISqlConnectionFactory { DbConnection CreateConnection(); -} \ No newline at end of file +} diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Sql/SqlConnectionStringHelper.cs b/src/Core/Infrastructure/Infrastructure.Repository/Sql/SqlConnectionStringHelper.cs index 27daeff..cab4e4e 100644 --- a/src/Core/Infrastructure/Infrastructure.Repository/Sql/SqlConnectionStringHelper.cs +++ b/src/Core/Infrastructure/Infrastructure.Repository/Sql/SqlConnectionStringHelper.cs @@ -58,4 +58,4 @@ public static class SqlConnectionStringHelper { return BuildConnectionString("master"); } -} \ No newline at end of file +} diff --git a/src/Core/Infrastructure/Infrastructure.Repository/UserAccount/IUserAccountRepository.cs b/src/Core/Infrastructure/Infrastructure.Repository/UserAccount/IUserAccountRepository.cs index 774f825..2a48851 100644 --- a/src/Core/Infrastructure/Infrastructure.Repository/UserAccount/IUserAccountRepository.cs +++ b/src/Core/Infrastructure/Infrastructure.Repository/UserAccount/IUserAccountRepository.cs @@ -9,8 +9,6 @@ public interface IUserAccountRepository ); Task UpdateAsync(Domain.Entities.UserAccount userAccount); Task DeleteAsync(Guid id); - Task GetByUsernameAsync( - string username - ); + Task GetByUsernameAsync(string username); Task GetByEmailAsync(string email); -} \ No newline at end of file +} diff --git a/src/Core/Infrastructure/Infrastructure.Repository/UserAccount/UserAccountRepository.cs b/src/Core/Infrastructure/Infrastructure.Repository/UserAccount/UserAccountRepository.cs index d07fa3c..8de64ba 100644 --- a/src/Core/Infrastructure/Infrastructure.Repository/UserAccount/UserAccountRepository.cs +++ b/src/Core/Infrastructure/Infrastructure.Repository/UserAccount/UserAccountRepository.cs @@ -8,9 +8,7 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory) : Repository(connectionFactory), IUserAccountRepository { - public async Task GetByIdAsync( - Guid id - ) + public async Task GetByIdAsync(Guid id) { await using var connection = await CreateConnection(); await using var command = connection.CreateCommand(); @@ -23,9 +21,10 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory) return await reader.ReadAsync() ? MapToEntity(reader) : null; } - public async Task< - IEnumerable - > GetAllAsync(int? limit, int? offset) + public async Task> GetAllAsync( + int? limit, + int? offset + ) { await using var connection = await CreateConnection(); await using var command = connection.CreateCommand(); @@ -49,9 +48,7 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory) return users; } - public async Task UpdateAsync( - Domain.Entities.UserAccount userAccount - ) + public async Task UpdateAsync(Domain.Entities.UserAccount userAccount) { await using var connection = await CreateConnection(); await using var command = connection.CreateCommand(); @@ -115,9 +112,7 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory) { return new Domain.Entities.UserAccount { - UserAccountId = reader.GetGuid( - reader.GetOrdinal("UserAccountId") - ), + UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")), Username = reader.GetString(reader.GetOrdinal("Username")), FirstName = reader.GetString(reader.GetOrdinal("FirstName")), LastName = reader.GetString(reader.GetOrdinal("LastName")), @@ -126,9 +121,7 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory) UpdatedAt = reader.IsDBNull(reader.GetOrdinal("UpdatedAt")) ? null : reader.GetDateTime(reader.GetOrdinal("UpdatedAt")), - DateOfBirth = reader.GetDateTime( - reader.GetOrdinal("DateOfBirth") - ), + DateOfBirth = reader.GetDateTime(reader.GetOrdinal("DateOfBirth")), Timer = reader.IsDBNull(reader.GetOrdinal("Timer")) ? null : (byte[])reader["Timer"], @@ -146,4 +139,4 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory) p.Value = value ?? DBNull.Value; command.Parameters.Add(p); } -} \ No newline at end of file +} diff --git a/src/Core/Service/Service.Auth.Tests/LoginService.test.cs b/src/Core/Service/Service.Auth.Tests/LoginService.test.cs new file mode 100644 index 0000000..b266d1e --- /dev/null +++ b/src/Core/Service/Service.Auth.Tests/LoginService.test.cs @@ -0,0 +1,238 @@ +using Domain.Entities; +using Domain.Exceptions; +using FluentAssertions; +using Infrastructure.PasswordHashing; +using Infrastructure.Repository.Auth; +using Moq; + +namespace Service.Auth.Tests; + +public class LoginServiceTest +{ + private readonly Mock _authRepoMock; + private readonly Mock _passwordInfraMock; + private readonly LoginService _loginService; + + public LoginServiceTest() + { + _authRepoMock = new Mock(); + _passwordInfraMock = new Mock(); + _loginService = new LoginService( + _authRepoMock.Object, + _passwordInfraMock.Object + ); + } + + // Happy path: login returns the user account with the same username -- successful login + [Fact] + public async Task LoginAsync_WithValidData_ReturnsUserAccountWithMatchingUsername() + { + // Arrange + const string username = "CogitoErgoSum"; + var userAccountId = Guid.NewGuid(); + + UserAccount userAccount = new() + { + UserAccountId = userAccountId, + Username = username, + FirstName = "René", + LastName = "Descartes", + Email = "r.descartes@example.com", + DateOfBirth = new DateTime(1596, 03, 31), + }; + + UserCredential userCredential = new() + { + UserCredentialId = Guid.NewGuid(), + UserAccountId = userAccountId, + Hash = "some-hash", + Expiry = DateTime.MaxValue, + }; + + _authRepoMock + .Setup(x => x.GetUserByUsernameAsync(username)) + .ReturnsAsync(userAccount); + + _authRepoMock + .Setup(x => + x.GetActiveCredentialByUserAccountIdAsync(userAccountId) + ) + .ReturnsAsync(userCredential); + + _passwordInfraMock + .Setup(x => x.Verify(It.IsAny(), It.IsAny())) + .Returns(true); + + // Act + var result = await _loginService.LoginAsync(username, It.IsAny()); + + // Assert + result.Should().NotBeNull(); + result.UserAccountId.Should().Be(userAccountId); + result.Username.Should().Be(username); + + _authRepoMock.Verify( + x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId), + Times.Once + ); + + _authRepoMock.Verify( + x => x.GetUserByUsernameAsync(username), + Times.Once + ); + + _passwordInfraMock.Verify( + x => x.Verify(It.IsAny(), It.IsAny()), + Times.Once + ); + } + + [Fact] + public async Task LoginAsync_WithUnregisteredUsername_ThrowsUnauthorizedException() + { + // Arrange + const string username = "de_beauvoir"; + + _authRepoMock + .Setup(x => x.GetUserByUsernameAsync(username)) + .ReturnsAsync((UserAccount?)null); + + // Act + var act = async () => + await _loginService.LoginAsync(username, It.IsAny()); + + // Assert + await act.Should().ThrowAsync(); + + _authRepoMock.Verify( + x => x.GetUserByUsernameAsync(username), + Times.Once + ); + + _authRepoMock.Verify( + x => x.GetActiveCredentialByUserAccountIdAsync(It.IsAny()), + Times.Never + ); + + _passwordInfraMock.Verify( + x => x.Verify(It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Fact] + public async Task LoginAsync_WithNoActiveCredential_ThrowsUnauthorizedException() + { + // Arrange + const string username = "BRussell"; + var userAccountId = Guid.NewGuid(); + + UserAccount userAccount = new() + { + UserAccountId = userAccountId, + Username = username, + FirstName = "Bertrand", + LastName = "Russell", + Email = "b.russell@example.co.uk", + DateOfBirth = new DateTime(1872, 05, 18), + }; + + _authRepoMock + .Setup(x => x.GetUserByUsernameAsync(username)) + .ReturnsAsync(userAccount); + + _authRepoMock + .Setup(x => + x.GetActiveCredentialByUserAccountIdAsync(userAccountId) + ) + .ReturnsAsync((UserCredential?)null); + + // Act + var act = async () => + await _loginService.LoginAsync(username, It.IsAny()); + + // Assert + await act.Should() + .ThrowAsync() + .WithMessage("Invalid username or password."); + + _authRepoMock.Verify( + x => x.GetUserByUsernameAsync(username), + Times.Once + ); + + _authRepoMock.Verify( + x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId), + Times.Once + ); + + _passwordInfraMock.Verify( + x => x.Verify(It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Fact] + public async Task LoginAsync_WithIncorrectPassword_ThrowsUnauthorizedException() + { + // Arrange + const string username = "RCarnap"; + var userAccountId = Guid.NewGuid(); + + UserAccount userAccount = new() + { + UserAccountId = userAccountId, + Username = username, + FirstName = "Rudolf", + LastName = "Carnap", + Email = "r.carnap@example.de", + DateOfBirth = new DateTime(1891, 05, 18), + }; + + UserCredential userCredential = new() + { + UserCredentialId = Guid.NewGuid(), + UserAccountId = userAccountId, + Hash = "hashed-password", + Expiry = DateTime.MaxValue, + }; + + _authRepoMock + .Setup(x => x.GetUserByUsernameAsync(username)) + .ReturnsAsync(userAccount); + + _authRepoMock + .Setup(x => + x.GetActiveCredentialByUserAccountIdAsync(userAccountId) + ) + .ReturnsAsync(userCredential); + + _passwordInfraMock + .Setup(x => x.Verify(It.IsAny(), It.IsAny())) + .Returns(false); + + // Act + var act = async () => + await _loginService.LoginAsync(username, It.IsAny()); + + // Assert + await act.Should() + .ThrowAsync() + .WithMessage("Invalid username or password."); + + _authRepoMock.Verify( + x => x.GetUserByUsernameAsync(username), + Times.Once + ); + + _authRepoMock.Verify( + x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId), + Times.Once + ); + + _passwordInfraMock.Verify( + x => x.Verify(It.IsAny(), It.IsAny()), + Times.Once + ); + } +} diff --git a/src/Core/Service/Service.Auth.Tests/RegisterService.test.cs b/src/Core/Service/Service.Auth.Tests/RegisterService.test.cs new file mode 100644 index 0000000..2b909bc --- /dev/null +++ b/src/Core/Service/Service.Auth.Tests/RegisterService.test.cs @@ -0,0 +1,501 @@ +using Domain.Entities; +using Domain.Exceptions; +using FluentAssertions; +using Infrastructure.Email; +using Infrastructure.Email.Templates.Rendering; +using Infrastructure.PasswordHashing; +using Infrastructure.Repository.Auth; +using Moq; + +namespace Service.Auth.Tests; + +public class RegisterServiceTest +{ + private readonly Mock _authRepoMock; + private readonly Mock _passwordInfraMock; + private readonly Mock _emailProviderMock; + private readonly Mock _emailTemplateProviderMock; + private readonly RegisterService _registerService; + + public RegisterServiceTest() + { + _authRepoMock = new Mock(); + _passwordInfraMock = new Mock(); + _emailProviderMock = new Mock(); + _emailTemplateProviderMock = new Mock(); + + _registerService = new RegisterService( + _authRepoMock.Object, + _passwordInfraMock.Object, + _emailProviderMock.Object, + _emailTemplateProviderMock.Object + ); + } + + [Fact] + public async Task RegisterAsync_WithValidData_CreatesUserAndSendsEmail() + { + // Arrange + var userAccount = new UserAccount + { + Username = "newuser", + FirstName = "John", + LastName = "Doe", + Email = "john.doe@example.com", + DateOfBirth = new DateTime(1990, 1, 1), + }; + + const string password = "SecurePassword123!"; + const string hashedPassword = "hashed_password_value"; + var expectedUserId = Guid.NewGuid(); + const string expectedEmailHtml = "Welcome!"; + + // Mock: No existing user + _authRepoMock + .Setup(x => x.GetUserByUsernameAsync(userAccount.Username)) + .ReturnsAsync((UserAccount?)null); + + _authRepoMock + .Setup(x => x.GetUserByEmailAsync(userAccount.Email)) + .ReturnsAsync((UserAccount?)null); + + // Mock: Password hashing + _passwordInfraMock + .Setup(x => x.Hash(password)) + .Returns(hashedPassword); + + // Mock: User registration + _authRepoMock + .Setup(x => + x.RegisterUserAsync( + userAccount.Username, + userAccount.FirstName, + userAccount.LastName, + userAccount.Email, + userAccount.DateOfBirth, + hashedPassword + ) + ) + .ReturnsAsync( + new UserAccount + { + UserAccountId = expectedUserId, + Username = userAccount.Username, + FirstName = userAccount.FirstName, + LastName = userAccount.LastName, + Email = userAccount.Email, + DateOfBirth = userAccount.DateOfBirth, + CreatedAt = DateTime.UtcNow, + } + ); + + // Mock: Email template rendering + _emailTemplateProviderMock + .Setup(x => + x.RenderUserRegisteredEmailAsync( + userAccount.FirstName, + It.IsAny() + ) + ) + .ReturnsAsync(expectedEmailHtml); + + // Mock: Email sending + _emailProviderMock + .Setup(x => + x.SendAsync( + userAccount.Email, + "Welcome to The Biergarten App!", + expectedEmailHtml, + true + ) + ) + .Returns(Task.CompletedTask); + + // Act + var result = await _registerService.RegisterAsync(userAccount, password); + + // Assert + result.Should().NotBeNull(); + result.UserAccountId.Should().Be(expectedUserId); + result.Username.Should().Be(userAccount.Username); + result.Email.Should().Be(userAccount.Email); + + // Verify all mocks were called as expected + _authRepoMock.Verify( + x => x.GetUserByUsernameAsync(userAccount.Username), + Times.Once + ); + _authRepoMock.Verify( + x => x.GetUserByEmailAsync(userAccount.Email), + Times.Once + ); + _passwordInfraMock.Verify(x => x.Hash(password), Times.Once); + _authRepoMock.Verify( + x => + x.RegisterUserAsync( + userAccount.Username, + userAccount.FirstName, + userAccount.LastName, + userAccount.Email, + userAccount.DateOfBirth, + hashedPassword + ), + Times.Once + ); + _emailTemplateProviderMock.Verify( + x => + x.RenderUserRegisteredEmailAsync( + userAccount.FirstName, + It.IsAny() + ), + Times.Once + ); + _emailProviderMock.Verify( + x => + x.SendAsync( + userAccount.Email, + "Welcome to The Biergarten App!", + expectedEmailHtml, + true + ), + Times.Once + ); + } + + [Fact] + public async Task RegisterAsync_WithExistingUsername_ThrowsConflictException() + { + // Arrange + var userAccount = new UserAccount + { + Username = "existinguser", + FirstName = "Jane", + LastName = "Smith", + Email = "jane.smith@example.com", + DateOfBirth = new DateTime(1995, 5, 15), + }; + var password = "Password123!"; + + var existingUser = new UserAccount + { + UserAccountId = Guid.NewGuid(), + Username = "existinguser", + FirstName = "Existing", + LastName = "User", + Email = "existing@example.com", + DateOfBirth = new DateTime(1990, 1, 1), + }; + + _authRepoMock + .Setup(x => x.GetUserByUsernameAsync(userAccount.Username)) + .ReturnsAsync(existingUser); + + _authRepoMock + .Setup(x => x.GetUserByEmailAsync(userAccount.Email)) + .ReturnsAsync((UserAccount?)null); + + // Act + var act = async () => await _registerService.RegisterAsync(userAccount, password); + + // Assert + await act.Should() + .ThrowAsync() + .WithMessage("Username or email already exists"); + + // Verify that registration was never called + _authRepoMock.Verify( + x => + x.RegisterUserAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ), + Times.Never + ); + + // Verify email was never sent + _emailProviderMock.Verify( + x => + x.SendAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ), + Times.Never + ); + } + + [Fact] + public async Task RegisterAsync_WithExistingEmail_ThrowsConflictException() + { + // Arrange + var userAccount = new UserAccount + { + Username = "newuser", + FirstName = "Jane", + LastName = "Smith", + Email = "existing@example.com", + DateOfBirth = new DateTime(1995, 5, 15), + }; + var password = "Password123!"; + + var existingUser = new UserAccount + { + UserAccountId = Guid.NewGuid(), + Username = "otheruser", + FirstName = "Existing", + LastName = "User", + Email = "existing@example.com", + DateOfBirth = new DateTime(1990, 1, 1), + }; + + _authRepoMock + .Setup(x => x.GetUserByUsernameAsync(userAccount.Username)) + .ReturnsAsync((UserAccount?)null); + + _authRepoMock + .Setup(x => x.GetUserByEmailAsync(userAccount.Email)) + .ReturnsAsync(existingUser); + + // Act + var act = async () => await _registerService.RegisterAsync(userAccount, password); + + // Assert + await act.Should() + .ThrowAsync() + .WithMessage("Username or email already exists"); + + _authRepoMock.Verify( + x => + x.RegisterUserAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ), + Times.Never + ); + } + + [Fact] + public async Task RegisterAsync_PasswordIsHashed_BeforeStoringInDatabase() + { + // Arrange + var userAccount = new UserAccount + { + Username = "secureuser", + FirstName = "Secure", + LastName = "User", + Email = "secure@example.com", + DateOfBirth = new DateTime(1990, 1, 1), + }; + var plainPassword = "PlainPassword123!"; + var hashedPassword = "hashed_secure_password"; + + _authRepoMock + .Setup(x => x.GetUserByUsernameAsync(It.IsAny())) + .ReturnsAsync((UserAccount?)null); + + _authRepoMock + .Setup(x => x.GetUserByEmailAsync(It.IsAny())) + .ReturnsAsync((UserAccount?)null); + + _passwordInfraMock + .Setup(x => x.Hash(plainPassword)) + .Returns(hashedPassword); + + _authRepoMock + .Setup(x => + x.RegisterUserAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + hashedPassword + ) + ) + .ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() }); + + _emailTemplateProviderMock + .Setup(x => + x.RenderUserRegisteredEmailAsync( + It.IsAny(), + It.IsAny() + ) + ) + .ReturnsAsync(""); + + // Act + await _registerService.RegisterAsync(userAccount, plainPassword); + + // Assert + _passwordInfraMock.Verify(x => x.Hash(plainPassword), Times.Once); + _authRepoMock.Verify( + x => + x.RegisterUserAsync( + userAccount.Username, + userAccount.FirstName, + userAccount.LastName, + userAccount.Email, + userAccount.DateOfBirth, + hashedPassword + ), // Verify hashed password is used + Times.Once + ); + } + + [Fact] + public async Task RegisterAsync_EmailConfirmationLink_ContainsUserEmail() + { + // Arrange + var userAccount = new UserAccount + { + Username = "testuser", + FirstName = "Test", + LastName = "User", + Email = "test@example.com", + DateOfBirth = new DateTime(1990, 1, 1), + }; + var password = "Password123!"; + string? capturedConfirmationLink = null; + + _authRepoMock + .Setup(x => x.GetUserByUsernameAsync(It.IsAny())) + .ReturnsAsync((UserAccount?)null); + + _authRepoMock + .Setup(x => x.GetUserByEmailAsync(It.IsAny())) + .ReturnsAsync((UserAccount?)null); + + _passwordInfraMock + .Setup(x => x.Hash(It.IsAny())) + .Returns("hashed"); + + _authRepoMock + .Setup(x => + x.RegisterUserAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ) + ) + .ReturnsAsync( + new UserAccount + { + UserAccountId = Guid.NewGuid(), + Username = userAccount.Username, + FirstName = userAccount.FirstName, + LastName = userAccount.LastName, + Email = userAccount.Email, + DateOfBirth = userAccount.DateOfBirth, + } + ); + + _emailTemplateProviderMock + .Setup(x => + x.RenderUserRegisteredEmailAsync( + It.IsAny(), + It.IsAny() + ) + ) + .Callback( + (_, link) => capturedConfirmationLink = link + ) + .ReturnsAsync(""); + + // Act + await _registerService.RegisterAsync(userAccount, password); + + // Assert + capturedConfirmationLink.Should().NotBeNull(); + capturedConfirmationLink + .Should() + .Contain(Uri.EscapeDataString(userAccount.Email)); + } + + [Fact] + public async Task RegisterAsync_WhenEmailSendingFails_ExceptionPropagates() + { + // Arrange + var userAccount = new UserAccount + { + Username = "testuser", + FirstName = "Test", + LastName = "User", + Email = "test@example.com", + DateOfBirth = new DateTime(1990, 1, 1), + }; + var password = "Password123!"; + + _authRepoMock + .Setup(x => x.GetUserByUsernameAsync(It.IsAny())) + .ReturnsAsync((UserAccount?)null); + + _authRepoMock + .Setup(x => x.GetUserByEmailAsync(It.IsAny())) + .ReturnsAsync((UserAccount?)null); + + _passwordInfraMock + .Setup(x => x.Hash(It.IsAny())) + .Returns("hashed"); + + _authRepoMock + .Setup(x => + x.RegisterUserAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ) + ) + .ReturnsAsync( + new UserAccount + { + UserAccountId = Guid.NewGuid(), + Email = userAccount.Email, + } + ); + + _emailTemplateProviderMock + .Setup(x => + x.RenderUserRegisteredEmailAsync( + It.IsAny(), + It.IsAny() + ) + ) + .ReturnsAsync(""); + + _emailProviderMock + .Setup(x => + x.SendAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ) + ) + .ThrowsAsync( + new InvalidOperationException("SMTP server unavailable") + ); + + // Act + var act = async () => await _registerService.RegisterAsync(userAccount, password); + + // Assert + await act.Should() + .ThrowAsync() + .WithMessage("SMTP server unavailable"); + } +} diff --git a/src/Core/Service/Service.Auth.Tests/Service.Auth.Tests.csproj b/src/Core/Service/Service.Auth.Tests/Service.Auth.Tests.csproj new file mode 100644 index 0000000..75f637d --- /dev/null +++ b/src/Core/Service/Service.Auth.Tests/Service.Auth.Tests.csproj @@ -0,0 +1,28 @@ + + + net10.0 + enable + enable + false + Service.Auth.Tests + Linux + + + + + + + + + + + + + + + + + + + + diff --git a/src/Core/Service/Service.Auth/Auth/ILoginService.cs b/src/Core/Service/Service.Auth/ILoginService.cs similarity index 68% rename from src/Core/Service/Service.Auth/Auth/ILoginService.cs rename to src/Core/Service/Service.Auth/ILoginService.cs index 9f9e022..f0bbf84 100644 --- a/src/Core/Service/Service.Auth/Auth/ILoginService.cs +++ b/src/Core/Service/Service.Auth/ILoginService.cs @@ -1,7 +1,6 @@ -using System.Threading.Tasks; using Domain.Entities; -namespace Service.Auth.Auth; +namespace Service.Auth; public interface ILoginService { diff --git a/src/Core/Service/Service.Auth/Auth/IRegisterService.cs b/src/Core/Service/Service.Auth/IRegisterService.cs similarity index 70% rename from src/Core/Service/Service.Auth/Auth/IRegisterService.cs rename to src/Core/Service/Service.Auth/IRegisterService.cs index 0cd5743..efb05b1 100644 --- a/src/Core/Service/Service.Auth/Auth/IRegisterService.cs +++ b/src/Core/Service/Service.Auth/IRegisterService.cs @@ -1,7 +1,6 @@ -using System.Threading.Tasks; using Domain.Entities; -namespace Service.Auth.Auth; +namespace Service.Auth; public interface IRegisterService { diff --git a/src/Core/Service/Service.Auth/Auth/LoginService.cs b/src/Core/Service/Service.Auth/LoginService.cs similarity index 94% rename from src/Core/Service/Service.Auth/Auth/LoginService.cs rename to src/Core/Service/Service.Auth/LoginService.cs index 754a5a4..2cb0902 100644 --- a/src/Core/Service/Service.Auth/Auth/LoginService.cs +++ b/src/Core/Service/Service.Auth/LoginService.cs @@ -1,10 +1,9 @@ -using System.Threading.Tasks; using Domain.Entities; using Domain.Exceptions; using Infrastructure.PasswordHashing; using Infrastructure.Repository.Auth; -namespace Service.Auth.Auth; +namespace Service.Auth; public class LoginService( IAuthRepository authRepo, diff --git a/src/Core/Service/Service.Auth/Auth/RegisterService.cs b/src/Core/Service/Service.Auth/RegisterService.cs similarity index 95% rename from src/Core/Service/Service.Auth/Auth/RegisterService.cs rename to src/Core/Service/Service.Auth/RegisterService.cs index 643876b..3554005 100644 --- a/src/Core/Service/Service.Auth/Auth/RegisterService.cs +++ b/src/Core/Service/Service.Auth/RegisterService.cs @@ -1,13 +1,11 @@ -using System.Threading.Tasks; using Domain.Entities; using Domain.Exceptions; using Infrastructure.Email; -using Infrastructure.Email.Templates; using Infrastructure.Email.Templates.Rendering; using Infrastructure.PasswordHashing; using Infrastructure.Repository.Auth; -namespace Service.Auth.Auth; +namespace Service.Auth; public class RegisterService( IAuthRepository authRepo,