Service refactor (#153)

* remove email out of register service

* Update auth service, move JWT handling out of controller

* add docker config for service auth test

* Update mock email system

* Format: ./src/Core/Service

* Refactor authentication payloads and services for registration and login processes

* Format: src/Core/API, src/Core/Service
This commit is contained in:
Aaron Po
2026-02-16 15:12:59 -05:00
committed by GitHub
parent 0d52c937ce
commit 2cad88e3f6
31 changed files with 762 additions and 484 deletions

View File

@@ -112,6 +112,26 @@ services:
networks: networks:
- testnet - testnet
service.auth.tests:
env_file: ".env.test"
image: service.auth.tests
container_name: test-env-service-auth-tests
depends_on:
database.seed:
condition: service_completed_successfully
build:
context: ./src/Core
dockerfile: Service/Service.Auth.Tests/Dockerfile
args:
BUILD_CONFIGURATION: Release
environment:
DOTNET_RUNNING_IN_CONTAINER: "true"
volumes:
- ./test-results:/app/test-results
restart: "no"
networks:
- testnet
volumes: volumes:
sqlserverdata-test: sqlserverdata-test:
driver: local driver: local

View File

@@ -20,12 +20,9 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" /> <ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Domain.Exceptions\Domain.Exceptions.csproj" /> <ProjectReference Include="..\..\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference <ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" /> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
<ProjectReference <ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.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" />
<ProjectReference Include="..\..\Service\Service.UserManagement\Service.UserManagement.csproj" /> <ProjectReference Include="..\..\Service\Service.UserManagement\Service.UserManagement.csproj" />

View File

@@ -1,4 +1,19 @@
using Domain.Entities;
using Org.BouncyCastle.Asn1.Cms;
namespace API.Core.Contracts.Auth; namespace API.Core.Contracts.Auth;
public record UserDTO(Guid UserAccountId, string Username); public record LoginPayload(
public record AuthPayload(UserDTO User, string AccessToken, DateTime CreatedAt, DateTime ExpiresAt); Guid UserAccountId,
string Username,
string RefreshToken,
string AccessToken
);
public record RegistrationPayload(
Guid UserAccountId,
string Username,
string RefreshToken,
string AccessToken,
bool ConfirmationEmailSent
);

View File

@@ -13,11 +13,8 @@ public class LoginRequestValidator : AbstractValidator<LoginRequest>
{ {
public LoginRequestValidator() public LoginRequestValidator()
{ {
RuleFor(x => x.Username) RuleFor(x => x.Username).NotEmpty().WithMessage("Username is required");
.NotEmpty().WithMessage("Username is required");
RuleFor(x => x.Password) RuleFor(x => x.Password).NotEmpty().WithMessage("Password is required");
.NotEmpty().WithMessage("Password is required");
} }
} }

View File

@@ -17,38 +17,55 @@ public class RegisterRequestValidator : AbstractValidator<RegisterRequest>
public RegisterRequestValidator() public RegisterRequestValidator()
{ {
RuleFor(x => x.Username) RuleFor(x => x.Username)
.NotEmpty().WithMessage("Username is required") .NotEmpty()
.Length(3, 64).WithMessage("Username must be between 3 and 64 characters") .WithMessage("Username is required")
.Length(3, 64)
.WithMessage("Username must be between 3 and 64 characters")
.Matches("^[a-zA-Z0-9._-]+$") .Matches("^[a-zA-Z0-9._-]+$")
.WithMessage("Username can only contain letters, numbers, dots, underscores, and hyphens"); .WithMessage(
"Username can only contain letters, numbers, dots, underscores, and hyphens"
);
RuleFor(x => x.FirstName) RuleFor(x => x.FirstName)
.NotEmpty().WithMessage("First name is required") .NotEmpty()
.MaximumLength(128).WithMessage("First name cannot exceed 128 characters"); .WithMessage("First name is required")
.MaximumLength(128)
.WithMessage("First name cannot exceed 128 characters");
RuleFor(x => x.LastName) RuleFor(x => x.LastName)
.NotEmpty().WithMessage("Last name is required") .NotEmpty()
.MaximumLength(128).WithMessage("Last name cannot exceed 128 characters"); .WithMessage("Last name is required")
.MaximumLength(128)
.WithMessage("Last name cannot exceed 128 characters");
RuleFor(x => x.Email) RuleFor(x => x.Email)
.NotEmpty().WithMessage("Email is required") .NotEmpty()
.EmailAddress().WithMessage("Invalid email format") .WithMessage("Email is required")
.MaximumLength(128).WithMessage("Email cannot exceed 128 characters"); .EmailAddress()
.WithMessage("Invalid email format")
.MaximumLength(128)
.WithMessage("Email cannot exceed 128 characters");
RuleFor(x => x.DateOfBirth) RuleFor(x => x.DateOfBirth)
.NotEmpty().WithMessage("Date of birth is required") .NotEmpty()
.WithMessage("Date of birth is required")
.LessThan(DateTime.Today.AddYears(-19)) .LessThan(DateTime.Today.AddYears(-19))
.WithMessage("You must be at least 19 years old to register"); .WithMessage("You must be at least 19 years old to register");
RuleFor(x => x.Password) RuleFor(x => x.Password)
.NotEmpty().WithMessage("Password is required") .NotEmpty()
.MinimumLength(8).WithMessage("Password must be at least 8 characters") .WithMessage("Password is required")
.Matches("[A-Z]").WithMessage("Password must contain at least one uppercase letter") .MinimumLength(8)
.Matches("[a-z]").WithMessage("Password must contain at least one lowercase letter") .WithMessage("Password must be at least 8 characters")
.Matches("[0-9]").WithMessage("Password must contain at least one number") .Matches("[A-Z]")
.Matches("[^a-zA-Z0-9]").WithMessage("Password must contain at least one special character"); .WithMessage("Password must contain at least one uppercase letter")
.Matches("[a-z]")
.WithMessage("Password must contain at least one lowercase letter")
.Matches("[0-9]")
.WithMessage("Password must contain at least one number")
.Matches("[^a-zA-Z0-9]")
.WithMessage(
"Password must contain at least one special character"
);
} }
} }

View File

@@ -1,7 +1,6 @@
using API.Core.Contracts.Auth; using API.Core.Contracts.Auth;
using API.Core.Contracts.Common; using API.Core.Contracts.Common;
using Domain.Entities; using Domain.Entities;
using Infrastructure.Jwt;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Service.Auth; using Service.Auth;
@@ -9,33 +8,37 @@ namespace API.Core.Controllers
{ {
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
public class AuthController(IRegisterService register, ILoginService login, ITokenInfrastructure tokenInfrastructure) : ControllerBase public class AuthController(IRegisterService register, ILoginService login)
: ControllerBase
{ {
[HttpPost("register")] [HttpPost("register")]
public async Task<ActionResult<UserAccount>> Register([FromBody] RegisterRequest req) public async Task<ActionResult<UserAccount>> Register(
[FromBody] RegisterRequest req
)
{ {
var created = await register.RegisterAsync(new UserAccount var rtn = await register.RegisterAsync(
new UserAccount
{ {
UserAccountId = Guid.Empty, UserAccountId = Guid.Empty,
Username = req.Username, Username = req.Username,
FirstName = req.FirstName, FirstName = req.FirstName,
LastName = req.LastName, LastName = req.LastName,
Email = req.Email, Email = req.Email,
DateOfBirth = req.DateOfBirth DateOfBirth = req.DateOfBirth,
}, req.Password); },
req.Password
var jwtExpiresAt = DateTime.UtcNow.AddHours(1);
var jwt = tokenInfrastructure.GenerateJwt(created.UserAccountId, created.Username, jwtExpiresAt
); );
var response = new ResponseBody<AuthPayload> var response = new ResponseBody<RegistrationPayload>
{ {
Message = "User registered successfully.", Message = "User registered successfully.",
Payload = new AuthPayload( Payload = new RegistrationPayload(
new UserDTO(created.UserAccountId, created.Username), rtn.UserAccount.UserAccountId,
jwt, rtn.UserAccount.Username,
DateTime.UtcNow, rtn.RefreshToken,
jwtExpiresAt) rtn.AccessToken,
rtn.EmailSent
),
}; };
return Created("/", response); return Created("/", response);
} }
@@ -43,18 +46,20 @@ namespace API.Core.Controllers
[HttpPost("login")] [HttpPost("login")]
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 rtn = await login.LoginAsync(req.Username, req.Password);
UserDTO dto = new(userAccount.UserAccountId, userAccount.Username); return Ok(
new ResponseBody<LoginPayload>
var jwtExpiresAt = DateTime.UtcNow.AddHours(1);
var jwt = tokenInfrastructure.GenerateJwt(userAccount.UserAccountId, userAccount.Username, jwtExpiresAt);
return Ok(new ResponseBody<AuthPayload>
{ {
Message = "Logged in successfully.", Message = "Logged in successfully.",
Payload = new AuthPayload(dto, jwt, DateTime.UtcNow, jwtExpiresAt) Payload = new LoginPayload(
}); rtn.UserAccount.UserAccountId,
rtn.UserAccount.Username,
rtn.RefreshToken,
rtn.AccessToken
),
}
);
} }
} }
} }

View File

@@ -9,7 +9,10 @@ namespace API.Core.Controllers
public class UserController(IUserService userService) : ControllerBase public class UserController(IUserService userService) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<ActionResult<IEnumerable<UserAccount>>> GetAll([FromQuery] int? limit, [FromQuery] int? offset) public async Task<ActionResult<IEnumerable<UserAccount>>> GetAll(
[FromQuery] int? limit,
[FromQuery] int? offset
)
{ {
var users = await userService.GetAllAsync(limit, offset); var users = await userService.GetAllAsync(limit, offset);
return Ok(users); return Ok(users);

View File

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

View File

@@ -1,7 +1,11 @@
using API.Core; using API.Core;
using API.Core.Contracts.Common;
using Domain.Exceptions; using Domain.Exceptions;
using FluentValidation; using FluentValidation;
using FluentValidation.AspNetCore; using FluentValidation.AspNetCore;
using Infrastructure.Email;
using Infrastructure.Email.Templates;
using Infrastructure.Email.Templates.Rendering;
using Infrastructure.Jwt; using Infrastructure.Jwt;
using Infrastructure.PasswordHashing; using Infrastructure.PasswordHashing;
using Infrastructure.Repository.Auth; using Infrastructure.Repository.Auth;
@@ -9,12 +13,8 @@ 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 Microsoft.AspNetCore.Mvc.Filters;
using Service.UserManagement.User;
using API.Core.Contracts.Common;
using Infrastructure.Email;
using Infrastructure.Email.Templates;
using Infrastructure.Email.Templates.Rendering;
using Service.Auth; using Service.Auth;
using Service.UserManagement.User;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -45,7 +45,10 @@ if (!builder.Environment.IsProduction())
// Configure Dependency Injection ------------------------------------------------------------------------------------- // Configure Dependency Injection -------------------------------------------------------------------------------------
builder.Services.AddSingleton<ISqlConnectionFactory, DefaultSqlConnectionFactory>(); builder.Services.AddSingleton<
ISqlConnectionFactory,
DefaultSqlConnectionFactory
>();
builder.Services.AddScoped<IUserAccountRepository, UserAccountRepository>(); builder.Services.AddScoped<IUserAccountRepository, UserAccountRepository>();
builder.Services.AddScoped<IAuthRepository, AuthRepository>(); builder.Services.AddScoped<IAuthRepository, AuthRepository>();
@@ -53,6 +56,7 @@ builder.Services.AddScoped<IAuthRepository, AuthRepository>();
builder.Services.AddScoped<IUserService, UserService>(); builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<ILoginService, LoginService>(); builder.Services.AddScoped<ILoginService, LoginService>();
builder.Services.AddScoped<IRegisterService, RegisterService>(); builder.Services.AddScoped<IRegisterService, RegisterService>();
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<ITokenInfrastructure, JwtInfrastructure>(); builder.Services.AddScoped<ITokenInfrastructure, JwtInfrastructure>();
builder.Services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>(); builder.Services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>();

View File

@@ -17,11 +17,17 @@
<!-- Reqnroll core, xUnit adapter and code-behind generator --> <!-- Reqnroll core, xUnit adapter and code-behind generator -->
<PackageReference Include="Reqnroll" Version="3.3.3" /> <PackageReference Include="Reqnroll" Version="3.3.3" />
<PackageReference Include="Reqnroll.xUnit" Version="3.3.3" /> <PackageReference Include="Reqnroll.xUnit" Version="3.3.3" />
<PackageReference Include="Reqnroll.Tools.MsBuild.Generation" Version="3.3.3" <PackageReference
PrivateAssets="all" /> Include="Reqnroll.Tools.MsBuild.Generation"
Version="3.3.3"
PrivateAssets="all"
/>
<!-- ASP.NET Core integration testing --> <!-- ASP.NET Core integration testing -->
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.1" /> <PackageReference
Include="Microsoft.AspNetCore.Mvc.Testing"
Version="9.0.1"
/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -35,7 +41,6 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\API.Core\API.Core.csproj" /> <ProjectReference Include="..\API.Core\API.Core.csproj" />
<ProjectReference <ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,68 @@
using Infrastructure.Email;
namespace API.Specs.Mocks;
/// <summary>
/// Mock email provider for testing that doesn't actually send emails.
/// Tracks sent emails for verification in tests if needed.
/// </summary>
public class MockEmailProvider : IEmailProvider
{
public List<SentEmail> SentEmails { get; } = new();
public Task SendAsync(
string to,
string subject,
string body,
bool isHtml = true
)
{
SentEmails.Add(
new SentEmail
{
To = [to],
Subject = subject,
Body = body,
IsHtml = isHtml,
SentAt = DateTime.UtcNow,
}
);
return Task.CompletedTask;
}
public Task SendAsync(
IEnumerable<string> to,
string subject,
string body,
bool isHtml = true
)
{
SentEmails.Add(
new SentEmail
{
To = to.ToList(),
Subject = subject,
Body = body,
IsHtml = isHtml,
SentAt = DateTime.UtcNow,
}
);
return Task.CompletedTask;
}
public void Clear()
{
SentEmails.Clear();
}
public class SentEmail
{
public List<string> To { get; init; } = new();
public string Subject { get; init; } = string.Empty;
public string Body { get; init; } = string.Empty;
public bool IsHtml { get; init; }
public DateTime SentAt { get; init; }
}
}

View File

@@ -1,54 +1,38 @@
using Infrastructure.Email; using Domain.Entities;
using Service.Emails;
namespace API.Specs.Mocks; namespace API.Specs.Mocks;
/// <summary> public class MockEmailService : IEmailService
/// Mock email service for testing that doesn't actually send emails.
/// Tracks sent emails for verification in tests if needed.
/// </summary>
public class MockEmailProvider : IEmailProvider
{ {
public List<SentEmail> SentEmails { get; } = new(); public List<RegistrationEmail> SentRegistrationEmails { get; } = new();
public Task SendAsync(string to, string subject, string body, bool isHtml = true) public Task SendRegistrationEmailAsync(
UserAccount createdUser,
string confirmationToken
)
{ {
SentEmails.Add(new SentEmail SentRegistrationEmails.Add(
new RegistrationEmail
{ {
To = [to], UserAccount = createdUser,
Subject = subject, ConfirmationToken = confirmationToken,
Body = body, SentAt = DateTime.UtcNow,
IsHtml = isHtml,
SentAt = DateTime.UtcNow
});
return Task.CompletedTask;
} }
);
public Task SendAsync(IEnumerable<string> to, string subject, string body, bool isHtml = true)
{
SentEmails.Add(new SentEmail
{
To = to.ToList(),
Subject = subject,
Body = body,
IsHtml = isHtml,
SentAt = DateTime.UtcNow
});
return Task.CompletedTask; return Task.CompletedTask;
} }
public void Clear() public void Clear()
{ {
SentEmails.Clear(); SentRegistrationEmails.Clear();
} }
public class SentEmail public class RegistrationEmail
{ {
public List<string> To { get; init; } = new(); public UserAccount UserAccount { get; init; } = null!;
public string Subject { get; init; } = string.Empty; public string ConfirmationToken { get; init; } = string.Empty;
public string Body { get; init; } = string.Empty;
public bool IsHtml { get; init; }
public DateTime SentAt { get; init; } public DateTime SentAt { get; init; }
} }
} }

View File

@@ -1,7 +1,7 @@
using System.Text.Json; using System.Text.Json;
using Reqnroll;
using FluentAssertions;
using API.Specs; using API.Specs;
using FluentAssertions;
using Reqnroll;
namespace API.Specs.Steps; namespace API.Specs.Steps;
@@ -20,7 +20,12 @@ public class ApiGeneralSteps(ScenarioContext scenario)
return client; return client;
} }
var factory = scenario.TryGetValue<TestApiFactory>(FactoryKey, out var f) ? f : new TestApiFactory(); var factory = scenario.TryGetValue<TestApiFactory>(
FactoryKey,
out var f
)
? f
: new TestApiFactory();
scenario[FactoryKey] = factory; scenario[FactoryKey] = factory;
client = factory.CreateClient(); client = factory.CreateClient();
@@ -35,13 +40,21 @@ public class ApiGeneralSteps(ScenarioContext scenario)
} }
[When("I send an HTTP request {string} to {string} with body:")] [When("I send an HTTP request {string} to {string} with body:")]
public async Task WhenISendAnHttpRequestStringToStringWithBody(string method, string url, string jsonBody) public async Task WhenISendAnHttpRequestStringToStringWithBody(
string method,
string url,
string jsonBody
)
{ {
var client = GetClient(); var client = GetClient();
var requestMessage = new HttpRequestMessage(new HttpMethod(method), url) var requestMessage = new HttpRequestMessage(new HttpMethod(method), url)
{ {
Content = new StringContent(jsonBody, System.Text.Encoding.UTF8, "application/json") Content = new StringContent(
jsonBody,
System.Text.Encoding.UTF8,
"application/json"
),
}; };
var response = await client.SendAsync(requestMessage); var response = await client.SendAsync(requestMessage);
@@ -52,10 +65,16 @@ public class ApiGeneralSteps(ScenarioContext scenario)
} }
[When("I send an HTTP request {string} to {string}")] [When("I send an HTTP request {string} to {string}")]
public async Task WhenISendAnHttpRequestStringToString(string method, string url) public async Task WhenISendAnHttpRequestStringToString(
string method,
string url
)
{ {
var client = GetClient(); var client = GetClient();
var requestMessage = new HttpRequestMessage(new HttpMethod(method), url); var requestMessage = new HttpRequestMessage(
new HttpMethod(method),
url
);
var response = await client.SendAsync(requestMessage); var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync(); var responseBody = await response.Content.ReadAsStringAsync();
@@ -66,34 +85,68 @@ public class ApiGeneralSteps(ScenarioContext scenario)
[Then("the response status code should be {int}")] [Then("the response status code should be {int}")]
public void ThenTheResponseStatusCodeShouldBeInt(int expected) public void ThenTheResponseStatusCodeShouldBeInt(int expected)
{ {
scenario.TryGetValue<HttpResponseMessage>(ResponseKey, out var response).Should().BeTrue(); scenario
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
.Should()
.BeTrue();
((int)response!.StatusCode).Should().Be(expected); ((int)response!.StatusCode).Should().Be(expected);
} }
[Then("the response has HTTP status {int}")] [Then("the response has HTTP status {int}")]
public void ThenTheResponseHasHttpStatusInt(int expectedCode) public void ThenTheResponseHasHttpStatusInt(int expectedCode)
{ {
scenario.TryGetValue<HttpResponseMessage>(ResponseKey, out var response).Should().BeTrue("No response was received from the API"); scenario
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
.Should()
.BeTrue("No response was received from the API");
((int)response!.StatusCode).Should().Be(expectedCode); ((int)response!.StatusCode).Should().Be(expectedCode);
} }
[Then("the response JSON should have {string} equal {string}")] [Then("the response JSON should have {string} equal {string}")]
public void ThenTheResponseJsonShouldHaveStringEqualString(string field, string expected) public void ThenTheResponseJsonShouldHaveStringEqualString(
string field,
string expected
)
{ {
scenario.TryGetValue<HttpResponseMessage>(ResponseKey, out var response).Should().BeTrue(); scenario
scenario.TryGetValue<string>(ResponseBodyKey, out var responseBody).Should().BeTrue(); .TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
.Should()
.BeTrue();
scenario
.TryGetValue<string>(ResponseBodyKey, out var responseBody)
.Should()
.BeTrue();
using var doc = JsonDocument.Parse(responseBody!); using var doc = JsonDocument.Parse(responseBody!);
var root = doc.RootElement; var root = doc.RootElement;
if (!root.TryGetProperty(field, out var value)) if (!root.TryGetProperty(field, out var value))
{ {
root.TryGetProperty("payload", out var payloadElem).Should().BeTrue("Expected field '{0}' to be present either at the root or inside 'payload'", field); root.TryGetProperty("payload", out var payloadElem)
payloadElem.ValueKind.Should().Be(JsonValueKind.Object, "payload must be an object"); .Should()
payloadElem.TryGetProperty(field, out value).Should().BeTrue("Expected field '{0}' to be present inside 'payload'", field); .BeTrue(
"Expected field '{0}' to be present either at the root or inside 'payload'",
field
);
payloadElem
.ValueKind.Should()
.Be(JsonValueKind.Object, "payload must be an object");
payloadElem
.TryGetProperty(field, out value)
.Should()
.BeTrue(
"Expected field '{0}' to be present inside 'payload'",
field
);
} }
value.ValueKind.Should().Be(JsonValueKind.String, "Expected field '{0}' to be a string", field); value
.ValueKind.Should()
.Be(
JsonValueKind.String,
"Expected field '{0}' to be a string",
field
);
value.GetString().Should().Be(expected); value.GetString().Should().Be(expected);
} }
} }

View File

@@ -1,7 +1,7 @@
using System.Text.Json; using System.Text.Json;
using Reqnroll;
using FluentAssertions;
using API.Specs; using API.Specs;
using FluentAssertions;
using Reqnroll;
namespace API.Specs.Steps; namespace API.Specs.Steps;
@@ -21,7 +21,12 @@ public class AuthSteps(ScenarioContext scenario)
return client; return client;
} }
var factory = scenario.TryGetValue<TestApiFactory>(FactoryKey, out var f) ? f : new TestApiFactory(); var factory = scenario.TryGetValue<TestApiFactory>(
FactoryKey,
out var f
)
? f
: new TestApiFactory();
scenario[FactoryKey] = factory; scenario[FactoryKey] = factory;
client = factory.CreateClient(); client = factory.CreateClient();
@@ -45,15 +50,25 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitALoginRequestWithAUsernameAndPassword() public async Task WhenISubmitALoginRequestWithAUsernameAndPassword()
{ {
var client = GetClient(); var client = GetClient();
var (username, password) = scenario.TryGetValue<(string username, string password)>(TestUserKey, out var user) var (username, password) = scenario.TryGetValue<(
string username,
string password
)>(TestUserKey, out var user)
? user ? user
: ("test.user", "password"); : ("test.user", "password");
var body = JsonSerializer.Serialize(new { username, password }); var body = JsonSerializer.Serialize(new { username, password });
var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login") var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
"/api/auth/login"
)
{ {
Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") Content = new StringContent(
body,
System.Text.Encoding.UTF8,
"application/json"
),
}; };
var response = await client.SendAsync(requestMessage); var response = await client.SendAsync(requestMessage);
@@ -69,9 +84,16 @@ public class AuthSteps(ScenarioContext scenario)
var client = GetClient(); var client = GetClient();
var body = JsonSerializer.Serialize(new { password = "test" }); var body = JsonSerializer.Serialize(new { password = "test" });
var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login") var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
"/api/auth/login"
)
{ {
Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") Content = new StringContent(
body,
System.Text.Encoding.UTF8,
"application/json"
),
}; };
var response = await client.SendAsync(requestMessage); var response = await client.SendAsync(requestMessage);
@@ -87,9 +109,16 @@ public class AuthSteps(ScenarioContext scenario)
var client = GetClient(); var client = GetClient();
var body = JsonSerializer.Serialize(new { username = "test" }); var body = JsonSerializer.Serialize(new { username = "test" });
var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login") var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
"/api/auth/login"
)
{ {
Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") Content = new StringContent(
body,
System.Text.Encoding.UTF8,
"application/json"
),
}; };
var response = await client.SendAsync(requestMessage); var response = await client.SendAsync(requestMessage);
@@ -103,9 +132,16 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitALoginRequestWithBothUsernameAndPasswordMissing() public async Task WhenISubmitALoginRequestWithBothUsernameAndPasswordMissing()
{ {
var client = GetClient(); var client = GetClient();
var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login") var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
"/api/auth/login"
)
{ {
Content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json") Content = new StringContent(
"{}",
System.Text.Encoding.UTF8,
"application/json"
),
}; };
var response = await client.SendAsync(requestMessage); var response = await client.SendAsync(requestMessage);
@@ -118,37 +154,55 @@ public class AuthSteps(ScenarioContext scenario)
[Then("the response JSON should have an access token")] [Then("the response JSON should have an access token")]
public void ThenTheResponseJsonShouldHaveAnAccessToken() public void ThenTheResponseJsonShouldHaveAnAccessToken()
{ {
scenario.TryGetValue<HttpResponseMessage>(ResponseKey, out var response).Should().BeTrue(); scenario
scenario.TryGetValue<string>(ResponseBodyKey, out var responseBody).Should().BeTrue(); .TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
.Should()
.BeTrue();
scenario
.TryGetValue<string>(ResponseBodyKey, out var responseBody)
.Should()
.BeTrue();
var doc = JsonDocument.Parse(responseBody!); var doc = JsonDocument.Parse(responseBody!);
var root = doc.RootElement; var root = doc.RootElement;
JsonElement tokenElem = default; JsonElement tokenElem = default;
var hasToken = false; var hasToken = false;
if (
if (root.TryGetProperty("payload", out var payloadElem) && payloadElem.ValueKind == JsonValueKind.Object) root.TryGetProperty("payload", out var payloadElem)
&& payloadElem.ValueKind == JsonValueKind.Object
)
{ {
hasToken = payloadElem.TryGetProperty("accessToken", out tokenElem) hasToken =
payloadElem.TryGetProperty("accessToken", out tokenElem)
|| payloadElem.TryGetProperty("AccessToken", out tokenElem); || payloadElem.TryGetProperty("AccessToken", out tokenElem);
} }
hasToken
hasToken.Should().BeTrue("Expected an access token either at the root or inside 'payload'"); .Should()
.BeTrue(
"Expected an access token either at the root or inside 'payload'"
);
var token = tokenElem.GetString(); var token = tokenElem.GetString();
token.Should().NotBeNullOrEmpty(); token.Should().NotBeNullOrEmpty();
} }
[When("I submit a login request using a GET request")] [When("I submit a login request using a GET request")]
public async Task WhenISubmitALoginRequestUsingAgetRequest() public async Task WhenISubmitALoginRequestUsingAgetRequest()
{ {
var client = GetClient(); var client = GetClient();
// testing GET // testing GET
var requestMessage = new HttpRequestMessage(HttpMethod.Get, "/api/auth/login") var requestMessage = new HttpRequestMessage(
HttpMethod.Get,
"/api/auth/login"
)
{ {
Content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json") Content = new StringContent(
"{}",
System.Text.Encoding.UTF8,
"application/json"
),
}; };
var response = await client.SendAsync(requestMessage); var response = await client.SendAsync(requestMessage);
@@ -184,14 +238,21 @@ public class AuthSteps(ScenarioContext scenario)
lastName, lastName,
email, email,
dateOfBirth, dateOfBirth,
password password,
}; };
var body = JsonSerializer.Serialize(registrationData); var body = JsonSerializer.Serialize(registrationData);
var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/register") var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
"/api/auth/register"
)
{ {
Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") Content = new StringContent(
body,
System.Text.Encoding.UTF8,
"application/json"
),
}; };
var response = await client.SendAsync(requestMessage); var response = await client.SendAsync(requestMessage);
@@ -205,9 +266,16 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitARegistrationRequestUsingAGetRequest() public async Task WhenISubmitARegistrationRequestUsingAGetRequest()
{ {
var client = GetClient(); var client = GetClient();
var requestMessage = new HttpRequestMessage(HttpMethod.Get, "/api/auth/register") var requestMessage = new HttpRequestMessage(
HttpMethod.Get,
"/api/auth/register"
)
{ {
Content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json") Content = new StringContent(
"{}",
System.Text.Encoding.UTF8,
"application/json"
),
}; };
var response = await client.SendAsync(requestMessage); var response = await client.SendAsync(requestMessage);

View File

@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Service.Emails;
namespace API.Specs namespace API.Specs
{ {
@@ -16,16 +17,29 @@ namespace API.Specs
builder.ConfigureServices(services => builder.ConfigureServices(services =>
{ {
// Replace the real email service with mock for testing // Replace the real email provider with mock for testing
var descriptor = services.SingleOrDefault( var emailProviderDescriptor = services.SingleOrDefault(d =>
d => d.ServiceType == typeof(IEmailProvider)); d.ServiceType == typeof(IEmailProvider)
);
if (descriptor != null) if (emailProviderDescriptor != null)
{ {
services.Remove(descriptor); services.Remove(emailProviderDescriptor);
} }
services.AddScoped<IEmailProvider, MockEmailProvider>(); services.AddScoped<IEmailProvider, MockEmailProvider>();
// Replace the real email service with mock for testing
var emailServiceDescriptor = services.SingleOrDefault(d =>
d.ServiceType == typeof(IEmailService)
);
if (emailServiceDescriptor != null)
{
services.Remove(emailServiceDescriptor);
}
services.AddScoped<IEmailService, MockEmailService>();
}); });
} }
} }

View File

@@ -21,6 +21,7 @@
</Folder> </Folder>
<Folder Name="/Service/"> <Folder Name="/Service/">
<Project Path="Service/Service.Auth.Tests/Service.Auth.Tests.csproj" /> <Project Path="Service/Service.Auth.Tests/Service.Auth.Tests.csproj" />
<Project Path="Service/Service.Emails/Service.Emails.csproj" />
<Project Path="Service/Service.UserManagement/Service.UserManagement.csproj" /> <Project Path="Service/Service.UserManagement/Service.UserManagement.csproj" />
<Project Path="Service\Service.Auth\Service.Auth.csproj" /> <Project Path="Service\Service.Auth\Service.Auth.csproj" />
</Folder> </Folder>

View File

@@ -0,0 +1,21 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
COPY ["Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"]
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
COPY ["Service/Service.Auth/Service.Auth.csproj", "Service/Service.Auth/"]
COPY ["Service/Service.Auth.Tests/Service.Auth.Tests.csproj", "Service/Service.Auth.Tests/"]
RUN dotnet restore "Service/Service.Auth.Tests/Service.Auth.Tests.csproj"
COPY . .
WORKDIR "/src/Service/Service.Auth.Tests"
RUN dotnet build "./Service.Auth.Tests.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS final
RUN mkdir -p /app/test-results/service-auth-tests
WORKDIR /src/Service/Service.Auth.Tests
ENTRYPOINT ["dotnet", "test", "./Service.Auth.Tests.csproj", "-c", "Release", "--logger", "trx;LogFileName=/app/test-results/service-auth-tests/results.trx"]

View File

@@ -11,15 +11,18 @@ public class LoginServiceTest
{ {
private readonly Mock<IAuthRepository> _authRepoMock; private readonly Mock<IAuthRepository> _authRepoMock;
private readonly Mock<IPasswordInfrastructure> _passwordInfraMock; private readonly Mock<IPasswordInfrastructure> _passwordInfraMock;
private readonly Mock<ITokenService> _tokenServiceMock;
private readonly LoginService _loginService; private readonly LoginService _loginService;
public LoginServiceTest() public LoginServiceTest()
{ {
_authRepoMock = new Mock<IAuthRepository>(); _authRepoMock = new Mock<IAuthRepository>();
_passwordInfraMock = new Mock<IPasswordInfrastructure>(); _passwordInfraMock = new Mock<IPasswordInfrastructure>();
_tokenServiceMock = new Mock<ITokenService>();
_loginService = new LoginService( _loginService = new LoginService(
_authRepoMock.Object, _authRepoMock.Object,
_passwordInfraMock.Object _passwordInfraMock.Object,
_tokenServiceMock.Object
); );
} }
@@ -63,13 +66,26 @@ public class LoginServiceTest
.Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>())) .Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>()))
.Returns(true); .Returns(true);
_tokenServiceMock
.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
.Returns("access-token");
_tokenServiceMock
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
.Returns("refresh-token");
// Act // Act
var result = await _loginService.LoginAsync(username, It.IsAny<string>()); var result = await _loginService.LoginAsync(
username,
It.IsAny<string>()
);
// Assert // Assert
result.Should().NotBeNull(); result.Should().NotBeNull();
result.UserAccountId.Should().Be(userAccountId); result.UserAccount.UserAccountId.Should().Be(userAccountId);
result.Username.Should().Be(username); result.UserAccount.Username.Should().Be(username);
result.AccessToken.Should().Be("access-token");
result.RefreshToken.Should().Be("refresh-token");
_authRepoMock.Verify( _authRepoMock.Verify(
x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId), x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId),

View File

@@ -1,11 +1,10 @@
using Domain.Entities; using Domain.Entities;
using Domain.Exceptions; using Domain.Exceptions;
using FluentAssertions; using FluentAssertions;
using Infrastructure.Email;
using Infrastructure.Email.Templates.Rendering;
using Infrastructure.PasswordHashing; using Infrastructure.PasswordHashing;
using Infrastructure.Repository.Auth; using Infrastructure.Repository.Auth;
using Moq; using Moq;
using Service.Emails;
namespace Service.Auth.Tests; namespace Service.Auth.Tests;
@@ -13,27 +12,27 @@ public class RegisterServiceTest
{ {
private readonly Mock<IAuthRepository> _authRepoMock; private readonly Mock<IAuthRepository> _authRepoMock;
private readonly Mock<IPasswordInfrastructure> _passwordInfraMock; private readonly Mock<IPasswordInfrastructure> _passwordInfraMock;
private readonly Mock<IEmailProvider> _emailProviderMock; private readonly Mock<ITokenService> _tokenServiceMock;
private readonly Mock<IEmailTemplateProvider> _emailTemplateProviderMock; private readonly Mock<IEmailService> _emailServiceMock; // todo handle email related test cases here
private readonly RegisterService _registerService; private readonly RegisterService _registerService;
public RegisterServiceTest() public RegisterServiceTest()
{ {
_authRepoMock = new Mock<IAuthRepository>(); _authRepoMock = new Mock<IAuthRepository>();
_passwordInfraMock = new Mock<IPasswordInfrastructure>(); _passwordInfraMock = new Mock<IPasswordInfrastructure>();
_emailProviderMock = new Mock<IEmailProvider>(); _tokenServiceMock = new Mock<ITokenService>();
_emailTemplateProviderMock = new Mock<IEmailTemplateProvider>(); _emailServiceMock = new Mock<IEmailService>();
_registerService = new RegisterService( _registerService = new RegisterService(
_authRepoMock.Object, _authRepoMock.Object,
_passwordInfraMock.Object, _passwordInfraMock.Object,
_emailProviderMock.Object, _tokenServiceMock.Object,
_emailTemplateProviderMock.Object _emailServiceMock.Object
); );
} }
[Fact] [Fact]
public async Task RegisterAsync_WithValidData_CreatesUserAndSendsEmail() public async Task RegisterAsync_WithValidData_CreatesUserAndReturnsAuthServiceReturn()
{ {
// Arrange // Arrange
var userAccount = new UserAccount var userAccount = new UserAccount
@@ -48,7 +47,6 @@ public class RegisterServiceTest
const string password = "SecurePassword123!"; const string password = "SecurePassword123!";
const string hashedPassword = "hashed_password_value"; const string hashedPassword = "hashed_password_value";
var expectedUserId = Guid.NewGuid(); var expectedUserId = Guid.NewGuid();
const string expectedEmailHtml = "<html><body>Welcome!</body></html>";
// Mock: No existing user // Mock: No existing user
_authRepoMock _authRepoMock
@@ -89,36 +87,28 @@ public class RegisterServiceTest
} }
); );
// Mock: Email template rendering // Mock: Token generation
_emailTemplateProviderMock _tokenServiceMock
.Setup(x => .Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
x.RenderUserRegisteredEmailAsync( .Returns("access-token");
userAccount.FirstName,
It.IsAny<string>()
)
)
.ReturnsAsync(expectedEmailHtml);
// Mock: Email sending _tokenServiceMock
_emailProviderMock .Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
.Setup(x => .Returns("refresh-token");
x.SendAsync(
userAccount.Email,
"Welcome to The Biergarten App!",
expectedEmailHtml,
true
)
)
.Returns(Task.CompletedTask);
// Act // Act
var result = await _registerService.RegisterAsync(userAccount, password); var result = await _registerService.RegisterAsync(
userAccount,
password
);
// Assert // Assert
result.Should().NotBeNull(); result.Should().NotBeNull();
result.UserAccountId.Should().Be(expectedUserId); result.UserAccount.UserAccountId.Should().Be(expectedUserId);
result.Username.Should().Be(userAccount.Username); result.UserAccount.Username.Should().Be(userAccount.Username);
result.Email.Should().Be(userAccount.Email); result.UserAccount.Email.Should().Be(userAccount.Email);
result.AccessToken.Should().Be("access-token");
result.RefreshToken.Should().Be("refresh-token");
// Verify all mocks were called as expected // Verify all mocks were called as expected
_authRepoMock.Verify( _authRepoMock.Verify(
@@ -142,24 +132,14 @@ public class RegisterServiceTest
), ),
Times.Once Times.Once
); );
_emailTemplateProviderMock.Verify( _emailServiceMock.Verify(
x => x =>
x.RenderUserRegisteredEmailAsync( x.SendRegistrationEmailAsync(
userAccount.FirstName, It.IsAny<UserAccount>(),
It.IsAny<string>() It.IsAny<string>()
), ),
Times.Once Times.Once
); );
_emailProviderMock.Verify(
x =>
x.SendAsync(
userAccount.Email,
"Welcome to The Biergarten App!",
expectedEmailHtml,
true
),
Times.Once
);
} }
[Fact] [Fact]
@@ -195,7 +175,8 @@ public class RegisterServiceTest
.ReturnsAsync((UserAccount?)null); .ReturnsAsync((UserAccount?)null);
// Act // Act
var act = async () => await _registerService.RegisterAsync(userAccount, password); var act = async () =>
await _registerService.RegisterAsync(userAccount, password);
// Assert // Assert
await act.Should() await act.Should()
@@ -215,18 +196,6 @@ public class RegisterServiceTest
), ),
Times.Never Times.Never
); );
// Verify email was never sent
_emailProviderMock.Verify(
x =>
x.SendAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<bool>()
),
Times.Never
);
} }
[Fact] [Fact]
@@ -262,7 +231,8 @@ public class RegisterServiceTest
.ReturnsAsync(existingUser); .ReturnsAsync(existingUser);
// Act // Act
var act = async () => await _registerService.RegisterAsync(userAccount, password); var act = async () =>
await _registerService.RegisterAsync(userAccount, password);
// Assert // Assert
await act.Should() await act.Should()
@@ -323,14 +293,13 @@ public class RegisterServiceTest
) )
.ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() }); .ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() });
_emailTemplateProviderMock _tokenServiceMock
.Setup(x => .Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
x.RenderUserRegisteredEmailAsync( .Returns("access-token");
It.IsAny<string>(),
It.IsAny<string>() _tokenServiceMock
) .Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
) .Returns("refresh-token");
.ReturnsAsync("<html></html>");
// Act // Act
await _registerService.RegisterAsync(userAccount, plainPassword); await _registerService.RegisterAsync(userAccount, plainPassword);
@@ -350,152 +319,4 @@ public class RegisterServiceTest
Times.Once 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<string>()))
.ReturnsAsync((UserAccount?)null);
_authRepoMock
.Setup(x => x.GetUserByEmailAsync(It.IsAny<string>()))
.ReturnsAsync((UserAccount?)null);
_passwordInfraMock
.Setup(x => x.Hash(It.IsAny<string>()))
.Returns("hashed");
_authRepoMock
.Setup(x =>
x.RegisterUserAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<DateTime>(),
It.IsAny<string>()
)
)
.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<string>(),
It.IsAny<string>()
)
)
.Callback<string, string>(
(_, link) => capturedConfirmationLink = link
)
.ReturnsAsync("<html></html>");
// 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<string>()))
.ReturnsAsync((UserAccount?)null);
_authRepoMock
.Setup(x => x.GetUserByEmailAsync(It.IsAny<string>()))
.ReturnsAsync((UserAccount?)null);
_passwordInfraMock
.Setup(x => x.Hash(It.IsAny<string>()))
.Returns("hashed");
_authRepoMock
.Setup(x =>
x.RegisterUserAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<DateTime>(),
It.IsAny<string>()
)
)
.ReturnsAsync(
new UserAccount
{
UserAccountId = Guid.NewGuid(),
Email = userAccount.Email,
}
);
_emailTemplateProviderMock
.Setup(x =>
x.RenderUserRegisteredEmailAsync(
It.IsAny<string>(),
It.IsAny<string>()
)
)
.ReturnsAsync("<html></html>");
_emailProviderMock
.Setup(x =>
x.SendAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<bool>()
)
)
.ThrowsAsync(
new InvalidOperationException("SMTP server unavailable")
);
// Act
var act = async () => await _registerService.RegisterAsync(userAccount, password);
// Assert
await act.Should()
.ThrowAsync<InvalidOperationException>()
.WithMessage("SMTP server unavailable");
}
} }

View File

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

View File

@@ -2,7 +2,38 @@ using Domain.Entities;
namespace Service.Auth; namespace Service.Auth;
public record RegisterServiceReturn
{
public bool IsAuthenticated { get; init; } = false;
public bool EmailSent { get; init; } = false;
public UserAccount UserAccount { get; init; }
public string AccessToken { get; init; } = string.Empty;
public string RefreshToken { get; init; } = string.Empty;
public RegisterServiceReturn(
UserAccount userAccount,
string accessToken,
string refreshToken,
bool emailSent
)
{
IsAuthenticated = true;
EmailSent = emailSent;
UserAccount = userAccount;
AccessToken = accessToken;
RefreshToken = refreshToken;
}
public RegisterServiceReturn(UserAccount userAccount)
{
UserAccount = userAccount;
}
}
public interface IRegisterService public interface IRegisterService
{ {
Task<UserAccount> RegisterAsync(UserAccount userAccount, string password); Task<RegisterServiceReturn> RegisterAsync(
UserAccount userAccount,
string password
);
} }

View File

@@ -0,0 +1,34 @@
using Domain.Entities;
using Infrastructure.Jwt;
namespace Service.Auth;
public interface ITokenService
{
public string GenerateAccessToken(UserAccount user);
public string GenerateRefreshToken(UserAccount user);
}
public class TokenService(ITokenInfrastructure tokenInfrastructure)
: ITokenService
{
public string GenerateAccessToken(UserAccount userAccount)
{
var jwtExpiresAt = DateTime.UtcNow.AddHours(1);
return tokenInfrastructure.GenerateJwt(
userAccount.UserAccountId,
userAccount.Username,
jwtExpiresAt
);
}
public string GenerateRefreshToken(UserAccount userAccount)
{
var jwtExpiresAt = DateTime.UtcNow.AddDays(21);
return tokenInfrastructure.GenerateJwt(
userAccount.UserAccountId,
userAccount.Username,
jwtExpiresAt
);
}
}

View File

@@ -5,30 +5,42 @@ using Infrastructure.Repository.Auth;
namespace Service.Auth; namespace Service.Auth;
public record LoginServiceReturn(
UserAccount UserAccount,
string RefreshToken,
string AccessToken
);
public class LoginService( public class LoginService(
IAuthRepository authRepo, IAuthRepository authRepo,
IPasswordInfrastructure passwordInfrastructure IPasswordInfrastructure passwordInfrastructure,
ITokenService tokenService
) : ILoginService ) : ILoginService
{ {
public async Task<LoginServiceReturn> LoginAsync(
public async Task<UserAccount> LoginAsync(string username, string password) string username,
string password
)
{ {
// Attempt lookup by username // Attempt lookup by username
var user = await authRepo.GetUserByUsernameAsync(username);
// the user was not found // the user was not found
if (user is null) var user =
throw new UnauthorizedException("Invalid username or password."); await authRepo.GetUserByUsernameAsync(username)
?? 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(
if (activeCred is null) user.UserAccountId
throw new UnauthorizedException("Invalid username or password."); )
?? throw new UnauthorizedException("Invalid username or password.");
if (!passwordInfrastructure.Verify(password, activeCred.Hash)) if (!passwordInfrastructure.Verify(password, activeCred.Hash))
throw new UnauthorizedException("Invalid username or password."); throw new UnauthorizedException("Invalid username or password.");
return user; string accessToken = tokenService.GenerateAccessToken(user);
string refreshToken = tokenService.GenerateRefreshToken(user);
return new LoginServiceReturn(user, refreshToken, accessToken);
} }
} }

View File

@@ -4,28 +4,40 @@ using Infrastructure.Email;
using Infrastructure.Email.Templates.Rendering; using Infrastructure.Email.Templates.Rendering;
using Infrastructure.PasswordHashing; using Infrastructure.PasswordHashing;
using Infrastructure.Repository.Auth; using Infrastructure.Repository.Auth;
using Microsoft.Extensions.Logging;
using Service.Emails;
namespace Service.Auth; namespace Service.Auth;
public class RegisterService( public class RegisterService(
IAuthRepository authRepo, IAuthRepository authRepo,
IPasswordInfrastructure passwordInfrastructure, IPasswordInfrastructure passwordInfrastructure,
IEmailProvider emailProvider, ITokenService tokenService,
IEmailTemplateProvider emailTemplateProvider IEmailService emailService
) : IRegisterService ) : IRegisterService
{ {
public async Task<UserAccount> RegisterAsync(UserAccount userAccount, string password) private async Task ValidateUserDoesNotExist(UserAccount userAccount)
{ {
// Check if user already exists // Check if user already exists
var existingUsername = await authRepo.GetUserByUsernameAsync(userAccount.Username); var existingUsername = await authRepo.GetUserByUsernameAsync(
var existingEmail = await authRepo.GetUserByEmailAsync(userAccount.Email); userAccount.Username
);
var existingEmail = await authRepo.GetUserByEmailAsync(
userAccount.Email
);
if (existingUsername != null || existingEmail != null) if (existingUsername != null || existingEmail != null)
{ {
throw new ConflictException("Username or email already exists"); throw new ConflictException("Username or email already exists");
} }
}
public async Task<RegisterServiceReturn> RegisterAsync(
UserAccount userAccount,
string password
)
{
await ValidateUserDoesNotExist(userAccount);
// password hashing // password hashing
var hashed = passwordInfrastructure.Hash(password); var hashed = passwordInfrastructure.Hash(password);
@@ -36,26 +48,41 @@ public class RegisterService(
userAccount.LastName, userAccount.LastName,
userAccount.Email, userAccount.Email,
userAccount.DateOfBirth, userAccount.DateOfBirth,
hashed); hashed
// Generate confirmation link (TODO: implement proper token-based confirmation)
var confirmationLink = $"https://thebiergarten.app/confirm?email={Uri.EscapeDataString(createdUser.Email)}";
// Render email template
var emailHtml = await emailTemplateProvider.RenderUserRegisteredEmailAsync(
createdUser.FirstName,
confirmationLink
); );
// Send welcome email with rendered template var accessToken = tokenService.GenerateAccessToken(createdUser);
await emailProvider.SendAsync( var refreshToken = tokenService.GenerateRefreshToken(createdUser);
createdUser.Email,
"Welcome to The Biergarten App!", if (
emailHtml, string.IsNullOrEmpty(accessToken)
isHtml: true || string.IsNullOrEmpty(refreshToken)
)
{
return new RegisterServiceReturn(createdUser);
}
bool emailSent = false;
try
{
// send confirmation email
await emailService.SendRegistrationEmailAsync(
createdUser,
"some-confirmation-token"
); );
return createdUser; emailSent = true;
}
catch
{
// ignored
}
return new RegisterServiceReturn(
createdUser,
accessToken,
refreshToken,
emailSent
);
} }
} }

View File

@@ -5,20 +5,14 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" /> <ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Domain.Exceptions\Domain.Exceptions.csproj" /> <ProjectReference Include="..\..\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference <ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" /> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
<ProjectReference <ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" /> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
<ProjectReference <ProjectReference Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj" />
Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" /> <ProjectReference Include="..\Service.Emails\Service.Emails.csproj" />
<ProjectReference
Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,41 @@
using Domain.Entities;
using Infrastructure.Email;
using Infrastructure.Email.Templates.Rendering;
namespace Service.Emails;
public interface IEmailService
{
public Task SendRegistrationEmailAsync(
UserAccount createdUser,
string confirmationToken
);
}
public class EmailService(
IEmailProvider emailProvider,
IEmailTemplateProvider emailTemplateProvider
) : IEmailService
{
public async Task SendRegistrationEmailAsync(
UserAccount createdUser,
string confirmationToken
)
{
var confirmationLink =
$"https://thebiergarten.app/confirm?token={confirmationToken}";
var emailHtml =
await emailTemplateProvider.RenderUserRegisteredEmailAsync(
createdUser.FirstName,
confirmationLink
);
await emailProvider.SendAsync(
createdUser.Email,
"Welcome to The Biergarten App!",
emailHtml,
isHtml: true
);
}
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
@@ -8,8 +7,6 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Domain.Exceptions\Domain.Exceptions.csproj" /> <ProjectReference Include="..\..\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference <ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -4,7 +4,10 @@ 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

@@ -6,7 +6,10 @@ namespace Service.UserManagement.User;
public class UserService(IUserAccountRepository repository) : IUserService public class UserService(IUserAccountRepository repository) : IUserService
{ {
public async Task<IEnumerable<UserAccount>> GetAllAsync(int? limit = null, int? offset = null) public async Task<IEnumerable<UserAccount>> GetAllAsync(
int? limit = null,
int? offset = null
)
{ {
return await repository.GetAllAsync(limit, offset); return await repository.GetAllAsync(limit, offset);
} }