diff --git a/docker-compose.test.yaml b/docker-compose.test.yaml
index 06aff2f..1704e87 100644
--- a/docker-compose.test.yaml
+++ b/docker-compose.test.yaml
@@ -112,6 +112,26 @@ services:
networks:
- 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:
sqlserverdata-test:
driver: local
diff --git a/src/Core/API/API.Core/API.Core.csproj b/src/Core/API/API.Core/API.Core.csproj
index 938fed6..5acab8a 100644
--- a/src/Core/API/API.Core/API.Core.csproj
+++ b/src/Core/API/API.Core/API.Core.csproj
@@ -20,12 +20,9 @@
-
-
-
+
+
+
diff --git a/src/Core/API/API.Core/Contracts/Auth/AuthDTO.cs b/src/Core/API/API.Core/Contracts/Auth/AuthDTO.cs
index 0d08dee..a544258 100644
--- a/src/Core/API/API.Core/Contracts/Auth/AuthDTO.cs
+++ b/src/Core/API/API.Core/Contracts/Auth/AuthDTO.cs
@@ -1,4 +1,19 @@
+using Domain.Entities;
+using Org.BouncyCastle.Asn1.Cms;
+
namespace API.Core.Contracts.Auth;
-public record UserDTO(Guid UserAccountId, string Username);
-public record AuthPayload(UserDTO User, string AccessToken, DateTime CreatedAt, DateTime ExpiresAt);
+public record LoginPayload(
+ Guid UserAccountId,
+ string Username,
+ string RefreshToken,
+ string AccessToken
+);
+
+public record RegistrationPayload(
+ Guid UserAccountId,
+ string Username,
+ string RefreshToken,
+ string AccessToken,
+ bool ConfirmationEmailSent
+);
diff --git a/src/Core/API/API.Core/Contracts/Auth/Login.cs b/src/Core/API/API.Core/Contracts/Auth/Login.cs
index 96a8126..c0bb5f8 100644
--- a/src/Core/API/API.Core/Contracts/Auth/Login.cs
+++ b/src/Core/API/API.Core/Contracts/Auth/Login.cs
@@ -5,19 +5,16 @@ namespace API.Core.Contracts.Auth;
public record LoginRequest
{
- public string Username { get; init; } = default!;
- public string Password { get; init; } = default!;
+ public string Username { get; init; } = default!;
+ public string Password { get; init; } = default!;
}
public class LoginRequestValidator : AbstractValidator
{
- public LoginRequestValidator()
- {
- RuleFor(x => x.Username)
- .NotEmpty().WithMessage("Username is required");
+ public LoginRequestValidator()
+ {
+ RuleFor(x => x.Username).NotEmpty().WithMessage("Username is required");
- RuleFor(x => x.Password)
- .NotEmpty().WithMessage("Password is required");
- }
+ RuleFor(x => x.Password).NotEmpty().WithMessage("Password is required");
+ }
}
-
diff --git a/src/Core/API/API.Core/Contracts/Auth/Register.cs b/src/Core/API/API.Core/Contracts/Auth/Register.cs
index 012575d..31c3541 100644
--- a/src/Core/API/API.Core/Contracts/Auth/Register.cs
+++ b/src/Core/API/API.Core/Contracts/Auth/Register.cs
@@ -17,38 +17,55 @@ public class RegisterRequestValidator : AbstractValidator
public RegisterRequestValidator()
{
RuleFor(x => x.Username)
- .NotEmpty().WithMessage("Username is required")
- .Length(3, 64).WithMessage("Username must be between 3 and 64 characters")
+ .NotEmpty()
+ .WithMessage("Username is required")
+ .Length(3, 64)
+ .WithMessage("Username must be between 3 and 64 characters")
.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)
- .NotEmpty().WithMessage("First name is required")
- .MaximumLength(128).WithMessage("First name cannot exceed 128 characters");
+ .NotEmpty()
+ .WithMessage("First name is required")
+ .MaximumLength(128)
+ .WithMessage("First name cannot exceed 128 characters");
RuleFor(x => x.LastName)
- .NotEmpty().WithMessage("Last name is required")
- .MaximumLength(128).WithMessage("Last name cannot exceed 128 characters");
+ .NotEmpty()
+ .WithMessage("Last name is required")
+ .MaximumLength(128)
+ .WithMessage("Last name cannot exceed 128 characters");
RuleFor(x => x.Email)
- .NotEmpty().WithMessage("Email is required")
- .EmailAddress().WithMessage("Invalid email format")
- .MaximumLength(128).WithMessage("Email cannot exceed 128 characters");
+ .NotEmpty()
+ .WithMessage("Email is required")
+ .EmailAddress()
+ .WithMessage("Invalid email format")
+ .MaximumLength(128)
+ .WithMessage("Email cannot exceed 128 characters");
RuleFor(x => x.DateOfBirth)
- .NotEmpty().WithMessage("Date of birth is required")
+ .NotEmpty()
+ .WithMessage("Date of birth is required")
.LessThan(DateTime.Today.AddYears(-19))
.WithMessage("You must be at least 19 years old to register");
RuleFor(x => x.Password)
- .NotEmpty().WithMessage("Password is required")
- .MinimumLength(8).WithMessage("Password must be at least 8 characters")
- .Matches("[A-Z]").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");
+ .NotEmpty()
+ .WithMessage("Password is required")
+ .MinimumLength(8)
+ .WithMessage("Password must be at least 8 characters")
+ .Matches("[A-Z]")
+ .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"
+ );
}
}
-
-
-
diff --git a/src/Core/API/API.Core/Contracts/Common/ResponseBody.cs b/src/Core/API/API.Core/Contracts/Common/ResponseBody.cs
index e5a6e71..12acd29 100644
--- a/src/Core/API/API.Core/Contracts/Common/ResponseBody.cs
+++ b/src/Core/API/API.Core/Contracts/Common/ResponseBody.cs
@@ -2,11 +2,11 @@ namespace API.Core.Contracts.Common;
public record ResponseBody
{
- public required string Message { get; init; }
- public required T Payload { get; init; }
+ public required string Message { get; init; }
+ public required T Payload { get; init; }
}
public record ResponseBody
{
- public required string Message { get; init; }
+ public required string Message { get; init; }
}
diff --git a/src/Core/API/API.Core/Controllers/AuthController.cs b/src/Core/API/API.Core/Controllers/AuthController.cs
index 7243141..4214a32 100644
--- a/src/Core/API/API.Core/Controllers/AuthController.cs
+++ b/src/Core/API/API.Core/Controllers/AuthController.cs
@@ -1,7 +1,6 @@
using API.Core.Contracts.Auth;
using API.Core.Contracts.Common;
using Domain.Entities;
-using Infrastructure.Jwt;
using Microsoft.AspNetCore.Mvc;
using Service.Auth;
@@ -9,33 +8,37 @@ namespace API.Core.Controllers
{
[ApiController]
[Route("api/[controller]")]
- public class AuthController(IRegisterService register, ILoginService login, ITokenInfrastructure tokenInfrastructure) : ControllerBase
+ public class AuthController(IRegisterService register, ILoginService login)
+ : ControllerBase
{
[HttpPost("register")]
- public async Task> Register([FromBody] RegisterRequest req)
+ public async Task> Register(
+ [FromBody] RegisterRequest req
+ )
{
- var created = await register.RegisterAsync(new UserAccount
- {
- UserAccountId = Guid.Empty,
- Username = req.Username,
- FirstName = req.FirstName,
- LastName = req.LastName,
- Email = req.Email,
- DateOfBirth = req.DateOfBirth
- }, req.Password);
-
- var jwtExpiresAt = DateTime.UtcNow.AddHours(1);
- var jwt = tokenInfrastructure.GenerateJwt(created.UserAccountId, created.Username, jwtExpiresAt
+ var rtn = await register.RegisterAsync(
+ new UserAccount
+ {
+ UserAccountId = Guid.Empty,
+ Username = req.Username,
+ FirstName = req.FirstName,
+ LastName = req.LastName,
+ Email = req.Email,
+ DateOfBirth = req.DateOfBirth,
+ },
+ req.Password
);
- var response = new ResponseBody
+ var response = new ResponseBody
{
Message = "User registered successfully.",
- Payload = new AuthPayload(
- new UserDTO(created.UserAccountId, created.Username),
- jwt,
- DateTime.UtcNow,
- jwtExpiresAt)
+ Payload = new RegistrationPayload(
+ rtn.UserAccount.UserAccountId,
+ rtn.UserAccount.Username,
+ rtn.RefreshToken,
+ rtn.AccessToken,
+ rtn.EmailSent
+ ),
};
return Created("/", response);
}
@@ -43,18 +46,20 @@ namespace API.Core.Controllers
[HttpPost("login")]
public async Task 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);
-
- var jwtExpiresAt = DateTime.UtcNow.AddHours(1);
- var jwt = tokenInfrastructure.GenerateJwt(userAccount.UserAccountId, userAccount.Username, jwtExpiresAt);
-
- return Ok(new ResponseBody
- {
- Message = "Logged in successfully.",
- Payload = new AuthPayload(dto, jwt, DateTime.UtcNow, jwtExpiresAt)
- });
+ return Ok(
+ new ResponseBody
+ {
+ Message = "Logged in successfully.",
+ Payload = new LoginPayload(
+ rtn.UserAccount.UserAccountId,
+ rtn.UserAccount.Username,
+ rtn.RefreshToken,
+ rtn.AccessToken
+ ),
+ }
+ );
}
}
}
diff --git a/src/Core/API/API.Core/Controllers/UserController.cs b/src/Core/API/API.Core/Controllers/UserController.cs
index 3cdd55a..7559373 100644
--- a/src/Core/API/API.Core/Controllers/UserController.cs
+++ b/src/Core/API/API.Core/Controllers/UserController.cs
@@ -9,7 +9,10 @@ namespace API.Core.Controllers
public class UserController(IUserService userService) : ControllerBase
{
[HttpGet]
- public async Task>> GetAll([FromQuery] int? limit, [FromQuery] int? offset)
+ public async Task>> GetAll(
+ [FromQuery] int? limit,
+ [FromQuery] int? offset
+ )
{
var users = await userService.GetAllAsync(limit, offset);
return Ok(users);
diff --git a/src/Core/API/API.Core/GlobalException.cs b/src/Core/API/API.Core/GlobalException.cs
index 9855e69..72f7821 100644
--- a/src/Core/API/API.Core/GlobalException.cs
+++ b/src/Core/API/API.Core/GlobalException.cs
@@ -8,7 +8,8 @@ using Microsoft.AspNetCore.Mvc.Filters;
namespace API.Core;
-public class GlobalExceptionFilter(ILogger logger) : IExceptionFilter
+public class GlobalExceptionFilter(ILogger logger)
+ : IExceptionFilter
{
public void OnException(ExceptionContext context)
{
@@ -17,65 +18,78 @@ public class GlobalExceptionFilter(ILogger logger) : IExc
switch (context.Exception)
{
case FluentValidation.ValidationException fluentValidationException:
- var errors = fluentValidationException.Errors
- .GroupBy(e => e.PropertyName)
+ var errors = fluentValidationException
+ .Errors.GroupBy(e => e.PropertyName)
.ToDictionary(
g => g.Key,
g => g.Select(e => e.ErrorMessage).ToArray()
);
- context.Result = new BadRequestObjectResult(new
- {
- message = "Validation failed",
- errors
- });
+ context.Result = new BadRequestObjectResult(
+ new { message = "Validation failed", errors }
+ );
context.ExceptionHandled = true;
break;
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;
break;
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;
break;
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;
break;
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;
break;
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;
break;
default:
- context.Result = new ObjectResult(new ResponseBody { Message = "An unexpected error occurred" })
+ context.Result = new ObjectResult(
+ new ResponseBody
+ {
+ Message = "An unexpected error occurred",
+ }
+ )
{
- StatusCode = 500
+ StatusCode = 500,
};
context.ExceptionHandled = true;
break;
diff --git a/src/Core/API/API.Core/Program.cs b/src/Core/API/API.Core/Program.cs
index ff70e6d..604ba4d 100644
--- a/src/Core/API/API.Core/Program.cs
+++ b/src/Core/API/API.Core/Program.cs
@@ -1,7 +1,11 @@
using API.Core;
+using API.Core.Contracts.Common;
using Domain.Exceptions;
using FluentValidation;
using FluentValidation.AspNetCore;
+using Infrastructure.Email;
+using Infrastructure.Email.Templates;
+using Infrastructure.Email.Templates.Rendering;
using Infrastructure.Jwt;
using Infrastructure.PasswordHashing;
using Infrastructure.Repository.Auth;
@@ -9,12 +13,8 @@ using Infrastructure.Repository.Sql;
using Infrastructure.Repository.UserAccount;
using Microsoft.AspNetCore.Mvc;
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.UserManagement.User;
var builder = WebApplication.CreateBuilder(args);
@@ -45,7 +45,10 @@ if (!builder.Environment.IsProduction())
// Configure Dependency Injection -------------------------------------------------------------------------------------
-builder.Services.AddSingleton();
+builder.Services.AddSingleton<
+ ISqlConnectionFactory,
+ DefaultSqlConnectionFactory
+>();
builder.Services.AddScoped();
builder.Services.AddScoped();
@@ -53,6 +56,7 @@ builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
diff --git a/src/Core/API/API.Specs/API.Specs.csproj b/src/Core/API/API.Specs/API.Specs.csproj
index 1a89003..e0a6e42 100644
--- a/src/Core/API/API.Specs/API.Specs.csproj
+++ b/src/Core/API/API.Specs/API.Specs.csproj
@@ -17,11 +17,17 @@
-
+
-
+
@@ -35,7 +41,6 @@
-
+
diff --git a/src/Core/API/API.Specs/Mocks/MockEmailProvider.cs b/src/Core/API/API.Specs/Mocks/MockEmailProvider.cs
new file mode 100644
index 0000000..0c8b113
--- /dev/null
+++ b/src/Core/API/API.Specs/Mocks/MockEmailProvider.cs
@@ -0,0 +1,68 @@
+using Infrastructure.Email;
+
+namespace API.Specs.Mocks;
+
+///
+/// Mock email provider for testing that doesn't actually send emails.
+/// Tracks sent emails for verification in tests if needed.
+///
+public class MockEmailProvider : IEmailProvider
+{
+ public List 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 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 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; }
+ }
+}
diff --git a/src/Core/API/API.Specs/Mocks/MockEmailService.cs b/src/Core/API/API.Specs/Mocks/MockEmailService.cs
index e864a0a..7a36528 100644
--- a/src/Core/API/API.Specs/Mocks/MockEmailService.cs
+++ b/src/Core/API/API.Specs/Mocks/MockEmailService.cs
@@ -1,54 +1,38 @@
-using Infrastructure.Email;
+using Domain.Entities;
+using Service.Emails;
namespace API.Specs.Mocks;
-///
-/// Mock email service for testing that doesn't actually send emails.
-/// Tracks sent emails for verification in tests if needed.
-///
-public class MockEmailProvider : IEmailProvider
+public class MockEmailService : IEmailService
{
- public List SentEmails { get; } = new();
+ public List SentRegistrationEmails { 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
- });
+ public Task SendRegistrationEmailAsync(
+ UserAccount createdUser,
+ string confirmationToken
+ )
+ {
+ SentRegistrationEmails.Add(
+ new RegistrationEmail
+ {
+ UserAccount = createdUser,
+ ConfirmationToken = confirmationToken,
+ SentAt = DateTime.UtcNow,
+ }
+ );
- return Task.CompletedTask;
- }
+ return Task.CompletedTask;
+ }
- public Task SendAsync(IEnumerable to, string subject, string body, bool isHtml = true)
- {
- SentEmails.Add(new SentEmail
- {
- To = to.ToList(),
- Subject = subject,
- Body = body,
- IsHtml = isHtml,
- SentAt = DateTime.UtcNow
- });
+ public void Clear()
+ {
+ SentRegistrationEmails.Clear();
+ }
- return Task.CompletedTask;
- }
-
- public void Clear()
- {
- SentEmails.Clear();
- }
-
- public class SentEmail
- {
- public List 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; }
- }
+ public class RegistrationEmail
+ {
+ public UserAccount UserAccount { get; init; } = null!;
+ public string ConfirmationToken { get; init; } = string.Empty;
+ public DateTime SentAt { get; init; }
+ }
}
diff --git a/src/Core/API/API.Specs/Steps/ApiGeneralSteps.cs b/src/Core/API/API.Specs/Steps/ApiGeneralSteps.cs
index eb8d411..8821c02 100644
--- a/src/Core/API/API.Specs/Steps/ApiGeneralSteps.cs
+++ b/src/Core/API/API.Specs/Steps/ApiGeneralSteps.cs
@@ -1,7 +1,7 @@
using System.Text.Json;
-using Reqnroll;
-using FluentAssertions;
using API.Specs;
+using FluentAssertions;
+using Reqnroll;
namespace API.Specs.Steps;
@@ -20,7 +20,12 @@ public class ApiGeneralSteps(ScenarioContext scenario)
return client;
}
- var factory = scenario.TryGetValue(FactoryKey, out var f) ? f : new TestApiFactory();
+ var factory = scenario.TryGetValue(
+ FactoryKey,
+ out var f
+ )
+ ? f
+ : new TestApiFactory();
scenario[FactoryKey] = factory;
client = factory.CreateClient();
@@ -35,13 +40,21 @@ public class ApiGeneralSteps(ScenarioContext scenario)
}
[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 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);
@@ -52,10 +65,16 @@ public class ApiGeneralSteps(ScenarioContext scenario)
}
[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 requestMessage = new HttpRequestMessage(new HttpMethod(method), url);
+ var requestMessage = new HttpRequestMessage(
+ new HttpMethod(method),
+ url
+ );
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
@@ -66,34 +85,68 @@ public class ApiGeneralSteps(ScenarioContext scenario)
[Then("the response status code should be {int}")]
public void ThenTheResponseStatusCodeShouldBeInt(int expected)
{
- scenario.TryGetValue(ResponseKey, out var response).Should().BeTrue();
+ scenario
+ .TryGetValue(ResponseKey, out var response)
+ .Should()
+ .BeTrue();
((int)response!.StatusCode).Should().Be(expected);
}
[Then("the response has HTTP status {int}")]
public void ThenTheResponseHasHttpStatusInt(int expectedCode)
{
- scenario.TryGetValue(ResponseKey, out var response).Should().BeTrue("No response was received from the API");
+ scenario
+ .TryGetValue(ResponseKey, out var response)
+ .Should()
+ .BeTrue("No response was received from the API");
((int)response!.StatusCode).Should().Be(expectedCode);
}
[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(ResponseKey, out var response).Should().BeTrue();
- scenario.TryGetValue(ResponseBodyKey, out var responseBody).Should().BeTrue();
+ scenario
+ .TryGetValue(ResponseKey, out var response)
+ .Should()
+ .BeTrue();
+ scenario
+ .TryGetValue(ResponseBodyKey, out var responseBody)
+ .Should()
+ .BeTrue();
using var doc = JsonDocument.Parse(responseBody!);
var root = doc.RootElement;
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);
- 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);
+ root.TryGetProperty("payload", out var payloadElem)
+ .Should()
+ .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);
}
}
diff --git a/src/Core/API/API.Specs/Steps/AuthSteps.cs b/src/Core/API/API.Specs/Steps/AuthSteps.cs
index adf145f..bfaabe0 100644
--- a/src/Core/API/API.Specs/Steps/AuthSteps.cs
+++ b/src/Core/API/API.Specs/Steps/AuthSteps.cs
@@ -1,7 +1,7 @@
using System.Text.Json;
-using Reqnroll;
-using FluentAssertions;
using API.Specs;
+using FluentAssertions;
+using Reqnroll;
namespace API.Specs.Steps;
@@ -21,7 +21,12 @@ public class AuthSteps(ScenarioContext scenario)
return client;
}
- var factory = scenario.TryGetValue(FactoryKey, out var f) ? f : new TestApiFactory();
+ var factory = scenario.TryGetValue(
+ FactoryKey,
+ out var f
+ )
+ ? f
+ : new TestApiFactory();
scenario[FactoryKey] = factory;
client = factory.CreateClient();
@@ -45,15 +50,25 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitALoginRequestWithAUsernameAndPassword()
{
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
: ("test.user", "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);
@@ -69,9 +84,16 @@ public class AuthSteps(ScenarioContext scenario)
var client = GetClient();
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);
@@ -87,9 +109,16 @@ public class AuthSteps(ScenarioContext scenario)
var client = GetClient();
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);
@@ -103,9 +132,16 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitALoginRequestWithBothUsernameAndPasswordMissing()
{
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);
@@ -118,37 +154,55 @@ public class AuthSteps(ScenarioContext scenario)
[Then("the response JSON should have an access token")]
public void ThenTheResponseJsonShouldHaveAnAccessToken()
{
- scenario.TryGetValue(ResponseKey, out var response).Should().BeTrue();
- scenario.TryGetValue(ResponseBodyKey, out var responseBody).Should().BeTrue();
+ scenario
+ .TryGetValue(ResponseKey, out var response)
+ .Should()
+ .BeTrue();
+ scenario
+ .TryGetValue(ResponseBodyKey, out var responseBody)
+ .Should()
+ .BeTrue();
var doc = JsonDocument.Parse(responseBody!);
var root = doc.RootElement;
JsonElement tokenElem = default;
var hasToken = false;
-
- if (root.TryGetProperty("payload", out var payloadElem) && payloadElem.ValueKind == JsonValueKind.Object)
+ if (
+ root.TryGetProperty("payload", out var payloadElem)
+ && payloadElem.ValueKind == JsonValueKind.Object
+ )
{
- hasToken = payloadElem.TryGetProperty("accessToken", out tokenElem)
- || payloadElem.TryGetProperty("AccessToken", out tokenElem);
+ hasToken =
+ payloadElem.TryGetProperty("accessToken", out tokenElem)
+ || payloadElem.TryGetProperty("AccessToken", out tokenElem);
}
-
- hasToken.Should().BeTrue("Expected an access token either at the root or inside 'payload'");
+ hasToken
+ .Should()
+ .BeTrue(
+ "Expected an access token either at the root or inside 'payload'"
+ );
var token = tokenElem.GetString();
token.Should().NotBeNullOrEmpty();
}
-
[When("I submit a login request using a GET request")]
public async Task WhenISubmitALoginRequestUsingAgetRequest()
{
var client = GetClient();
// 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);
@@ -184,14 +238,21 @@ public class AuthSteps(ScenarioContext scenario)
lastName,
email,
dateOfBirth,
- password
+ password,
};
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);
@@ -205,9 +266,16 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitARegistrationRequestUsingAGetRequest()
{
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);
diff --git a/src/Core/API/API.Specs/TestApiFactory.cs b/src/Core/API/API.Specs/TestApiFactory.cs
index ad63065..2b32445 100644
--- a/src/Core/API/API.Specs/TestApiFactory.cs
+++ b/src/Core/API/API.Specs/TestApiFactory.cs
@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Service.Emails;
namespace API.Specs
{
@@ -16,16 +17,29 @@ namespace API.Specs
builder.ConfigureServices(services =>
{
- // Replace the real email service with mock for testing
- var descriptor = services.SingleOrDefault(
- d => d.ServiceType == typeof(IEmailProvider));
+ // Replace the real email provider with mock for testing
+ var emailProviderDescriptor = services.SingleOrDefault(d =>
+ d.ServiceType == typeof(IEmailProvider)
+ );
- if (descriptor != null)
+ if (emailProviderDescriptor != null)
{
- services.Remove(descriptor);
+ services.Remove(emailProviderDescriptor);
}
services.AddScoped();
+
+ // 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();
});
}
}
diff --git a/src/Core/Core.slnx b/src/Core/Core.slnx
index a088dcd..4d6a6f1 100644
--- a/src/Core/Core.slnx
+++ b/src/Core/Core.slnx
@@ -21,6 +21,7 @@
+
diff --git a/src/Core/Service/Service.Auth.Tests/Dockerfile b/src/Core/Service/Service.Auth.Tests/Dockerfile
new file mode 100644
index 0000000..0d67ee9
--- /dev/null
+++ b/src/Core/Service/Service.Auth.Tests/Dockerfile
@@ -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"]
diff --git a/src/Core/Service/Service.Auth.Tests/LoginService.test.cs b/src/Core/Service/Service.Auth.Tests/LoginService.test.cs
index b266d1e..12f2e20 100644
--- a/src/Core/Service/Service.Auth.Tests/LoginService.test.cs
+++ b/src/Core/Service/Service.Auth.Tests/LoginService.test.cs
@@ -11,15 +11,18 @@ public class LoginServiceTest
{
private readonly Mock _authRepoMock;
private readonly Mock _passwordInfraMock;
+ private readonly Mock _tokenServiceMock;
private readonly LoginService _loginService;
public LoginServiceTest()
{
_authRepoMock = new Mock();
_passwordInfraMock = new Mock();
+ _tokenServiceMock = new Mock();
_loginService = new LoginService(
_authRepoMock.Object,
- _passwordInfraMock.Object
+ _passwordInfraMock.Object,
+ _tokenServiceMock.Object
);
}
@@ -63,13 +66,26 @@ public class LoginServiceTest
.Setup(x => x.Verify(It.IsAny(), It.IsAny()))
.Returns(true);
+ _tokenServiceMock
+ .Setup(x => x.GenerateAccessToken(It.IsAny()))
+ .Returns("access-token");
+
+ _tokenServiceMock
+ .Setup(x => x.GenerateRefreshToken(It.IsAny()))
+ .Returns("refresh-token");
+
// Act
- var result = await _loginService.LoginAsync(username, It.IsAny());
+ var result = await _loginService.LoginAsync(
+ username,
+ It.IsAny()
+ );
// Assert
result.Should().NotBeNull();
- result.UserAccountId.Should().Be(userAccountId);
- result.Username.Should().Be(username);
+ result.UserAccount.UserAccountId.Should().Be(userAccountId);
+ result.UserAccount.Username.Should().Be(username);
+ result.AccessToken.Should().Be("access-token");
+ result.RefreshToken.Should().Be("refresh-token");
_authRepoMock.Verify(
x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId),
diff --git a/src/Core/Service/Service.Auth.Tests/RegisterService.test.cs b/src/Core/Service/Service.Auth.Tests/RegisterService.test.cs
index 2b909bc..3f71644 100644
--- a/src/Core/Service/Service.Auth.Tests/RegisterService.test.cs
+++ b/src/Core/Service/Service.Auth.Tests/RegisterService.test.cs
@@ -1,11 +1,10 @@
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;
+using Service.Emails;
namespace Service.Auth.Tests;
@@ -13,27 +12,27 @@ public class RegisterServiceTest
{
private readonly Mock _authRepoMock;
private readonly Mock _passwordInfraMock;
- private readonly Mock _emailProviderMock;
- private readonly Mock _emailTemplateProviderMock;
+ private readonly Mock _tokenServiceMock;
+ private readonly Mock _emailServiceMock; // todo handle email related test cases here
private readonly RegisterService _registerService;
public RegisterServiceTest()
{
_authRepoMock = new Mock();
_passwordInfraMock = new Mock();
- _emailProviderMock = new Mock();
- _emailTemplateProviderMock = new Mock();
+ _tokenServiceMock = new Mock();
+ _emailServiceMock = new Mock();
_registerService = new RegisterService(
_authRepoMock.Object,
_passwordInfraMock.Object,
- _emailProviderMock.Object,
- _emailTemplateProviderMock.Object
+ _tokenServiceMock.Object,
+ _emailServiceMock.Object
);
}
[Fact]
- public async Task RegisterAsync_WithValidData_CreatesUserAndSendsEmail()
+ public async Task RegisterAsync_WithValidData_CreatesUserAndReturnsAuthServiceReturn()
{
// Arrange
var userAccount = new UserAccount
@@ -48,7 +47,6 @@ public class RegisterServiceTest
const string password = "SecurePassword123!";
const string hashedPassword = "hashed_password_value";
var expectedUserId = Guid.NewGuid();
- const string expectedEmailHtml = "Welcome!";
// Mock: No existing user
_authRepoMock
@@ -89,36 +87,28 @@ public class RegisterServiceTest
}
);
- // Mock: Email template rendering
- _emailTemplateProviderMock
- .Setup(x =>
- x.RenderUserRegisteredEmailAsync(
- userAccount.FirstName,
- It.IsAny()
- )
- )
- .ReturnsAsync(expectedEmailHtml);
+ // Mock: Token generation
+ _tokenServiceMock
+ .Setup(x => x.GenerateAccessToken(It.IsAny()))
+ .Returns("access-token");
- // Mock: Email sending
- _emailProviderMock
- .Setup(x =>
- x.SendAsync(
- userAccount.Email,
- "Welcome to The Biergarten App!",
- expectedEmailHtml,
- true
- )
- )
- .Returns(Task.CompletedTask);
+ _tokenServiceMock
+ .Setup(x => x.GenerateRefreshToken(It.IsAny()))
+ .Returns("refresh-token");
// Act
- var result = await _registerService.RegisterAsync(userAccount, password);
+ 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);
+ result.UserAccount.UserAccountId.Should().Be(expectedUserId);
+ result.UserAccount.Username.Should().Be(userAccount.Username);
+ 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
_authRepoMock.Verify(
@@ -142,24 +132,14 @@ public class RegisterServiceTest
),
Times.Once
);
- _emailTemplateProviderMock.Verify(
+ _emailServiceMock.Verify(
x =>
- x.RenderUserRegisteredEmailAsync(
- userAccount.FirstName,
+ x.SendRegistrationEmailAsync(
+ It.IsAny(),
It.IsAny()
),
Times.Once
);
- _emailProviderMock.Verify(
- x =>
- x.SendAsync(
- userAccount.Email,
- "Welcome to The Biergarten App!",
- expectedEmailHtml,
- true
- ),
- Times.Once
- );
}
[Fact]
@@ -195,7 +175,8 @@ public class RegisterServiceTest
.ReturnsAsync((UserAccount?)null);
// Act
- var act = async () => await _registerService.RegisterAsync(userAccount, password);
+ var act = async () =>
+ await _registerService.RegisterAsync(userAccount, password);
// Assert
await act.Should()
@@ -215,18 +196,6 @@ public class RegisterServiceTest
),
Times.Never
);
-
- // Verify email was never sent
- _emailProviderMock.Verify(
- x =>
- x.SendAsync(
- It.IsAny(),
- It.IsAny(),
- It.IsAny(),
- It.IsAny()
- ),
- Times.Never
- );
}
[Fact]
@@ -262,7 +231,8 @@ public class RegisterServiceTest
.ReturnsAsync(existingUser);
// Act
- var act = async () => await _registerService.RegisterAsync(userAccount, password);
+ var act = async () =>
+ await _registerService.RegisterAsync(userAccount, password);
// Assert
await act.Should()
@@ -323,14 +293,13 @@ public class RegisterServiceTest
)
.ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() });
- _emailTemplateProviderMock
- .Setup(x =>
- x.RenderUserRegisteredEmailAsync(
- It.IsAny(),
- It.IsAny()
- )
- )
- .ReturnsAsync("");
+ _tokenServiceMock
+ .Setup(x => x.GenerateAccessToken(It.IsAny()))
+ .Returns("access-token");
+
+ _tokenServiceMock
+ .Setup(x => x.GenerateRefreshToken(It.IsAny()))
+ .Returns("refresh-token");
// Act
await _registerService.RegisterAsync(userAccount, plainPassword);
@@ -350,152 +319,4 @@ public class RegisterServiceTest
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/ILoginService.cs b/src/Core/Service/Service.Auth/ILoginService.cs
index f0bbf84..9554a36 100644
--- a/src/Core/Service/Service.Auth/ILoginService.cs
+++ b/src/Core/Service/Service.Auth/ILoginService.cs
@@ -4,5 +4,5 @@ namespace Service.Auth;
public interface ILoginService
{
- Task LoginAsync(string username, string password);
+ Task LoginAsync(string username, string password);
}
diff --git a/src/Core/Service/Service.Auth/IRegisterService.cs b/src/Core/Service/Service.Auth/IRegisterService.cs
index efb05b1..3f1fae6 100644
--- a/src/Core/Service/Service.Auth/IRegisterService.cs
+++ b/src/Core/Service/Service.Auth/IRegisterService.cs
@@ -2,7 +2,38 @@ using Domain.Entities;
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
{
- Task RegisterAsync(UserAccount userAccount, string password);
+ Task RegisterAsync(
+ UserAccount userAccount,
+ string password
+ );
}
diff --git a/src/Core/Service/Service.Auth/ITokenService.cs b/src/Core/Service/Service.Auth/ITokenService.cs
new file mode 100644
index 0000000..3d2f0f8
--- /dev/null
+++ b/src/Core/Service/Service.Auth/ITokenService.cs
@@ -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
+ );
+ }
+}
diff --git a/src/Core/Service/Service.Auth/LoginService.cs b/src/Core/Service/Service.Auth/LoginService.cs
index 2cb0902..4cd0bb3 100644
--- a/src/Core/Service/Service.Auth/LoginService.cs
+++ b/src/Core/Service/Service.Auth/LoginService.cs
@@ -5,30 +5,42 @@ using Infrastructure.Repository.Auth;
namespace Service.Auth;
+public record LoginServiceReturn(
+ UserAccount UserAccount,
+ string RefreshToken,
+ string AccessToken
+);
+
public class LoginService(
IAuthRepository authRepo,
- IPasswordInfrastructure passwordInfrastructure
+ IPasswordInfrastructure passwordInfrastructure,
+ ITokenService tokenService
) : ILoginService
{
-
- public async Task LoginAsync(string username, string password)
+ public async Task LoginAsync(
+ string username,
+ string password
+ )
{
// Attempt lookup by username
- var user = await authRepo.GetUserByUsernameAsync(username);
-
// the user was not found
- if (user is null)
- throw new UnauthorizedException("Invalid username or password.");
+ var user =
+ await authRepo.GetUserByUsernameAsync(username)
+ ?? throw new UnauthorizedException("Invalid username or password.");
// @todo handle expired passwords
- var activeCred = await authRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId);
-
- if (activeCred is null)
- throw new UnauthorizedException("Invalid username or password.");
+ var activeCred =
+ await authRepo.GetActiveCredentialByUserAccountIdAsync(
+ user.UserAccountId
+ )
+ ?? throw new UnauthorizedException("Invalid username or password.");
if (!passwordInfrastructure.Verify(password, activeCred.Hash))
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);
}
}
diff --git a/src/Core/Service/Service.Auth/RegisterService.cs b/src/Core/Service/Service.Auth/RegisterService.cs
index 3554005..89e33ba 100644
--- a/src/Core/Service/Service.Auth/RegisterService.cs
+++ b/src/Core/Service/Service.Auth/RegisterService.cs
@@ -4,28 +4,40 @@ using Infrastructure.Email;
using Infrastructure.Email.Templates.Rendering;
using Infrastructure.PasswordHashing;
using Infrastructure.Repository.Auth;
+using Microsoft.Extensions.Logging;
+using Service.Emails;
namespace Service.Auth;
public class RegisterService(
IAuthRepository authRepo,
IPasswordInfrastructure passwordInfrastructure,
- IEmailProvider emailProvider,
- IEmailTemplateProvider emailTemplateProvider
+ ITokenService tokenService,
+ IEmailService emailService
) : IRegisterService
{
- public async Task RegisterAsync(UserAccount userAccount, string password)
+ private async Task ValidateUserDoesNotExist(UserAccount userAccount)
{
// Check if user already exists
- var existingUsername = await authRepo.GetUserByUsernameAsync(userAccount.Username);
- var existingEmail = await authRepo.GetUserByEmailAsync(userAccount.Email);
+ var existingUsername = await authRepo.GetUserByUsernameAsync(
+ userAccount.Username
+ );
+ var existingEmail = await authRepo.GetUserByEmailAsync(
+ userAccount.Email
+ );
if (existingUsername != null || existingEmail != null)
{
throw new ConflictException("Username or email already exists");
}
+ }
-
+ public async Task RegisterAsync(
+ UserAccount userAccount,
+ string password
+ )
+ {
+ await ValidateUserDoesNotExist(userAccount);
// password hashing
var hashed = passwordInfrastructure.Hash(password);
@@ -36,26 +48,41 @@ public class RegisterService(
userAccount.LastName,
userAccount.Email,
userAccount.DateOfBirth,
- 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
+ hashed
);
- // Send welcome email with rendered template
- await emailProvider.SendAsync(
- createdUser.Email,
- "Welcome to The Biergarten App!",
- emailHtml,
- isHtml: true
- );
+ var accessToken = tokenService.GenerateAccessToken(createdUser);
+ var refreshToken = tokenService.GenerateRefreshToken(createdUser);
- return createdUser;
+ if (
+ string.IsNullOrEmpty(accessToken)
+ || string.IsNullOrEmpty(refreshToken)
+ )
+ {
+ return new RegisterServiceReturn(createdUser);
+ }
+
+ bool emailSent = false;
+ try
+ {
+ // send confirmation email
+ await emailService.SendRegistrationEmailAsync(
+ createdUser,
+ "some-confirmation-token"
+ );
+
+ emailSent = true;
+ }
+ catch
+ {
+ // ignored
+ }
+
+ return new RegisterServiceReturn(
+ createdUser,
+ accessToken,
+ refreshToken,
+ emailSent
+ );
}
}
diff --git a/src/Core/Service/Service.Auth/Service.Auth.csproj b/src/Core/Service/Service.Auth/Service.Auth.csproj
index b7efbea..fd88236 100644
--- a/src/Core/Service/Service.Auth/Service.Auth.csproj
+++ b/src/Core/Service/Service.Auth/Service.Auth.csproj
@@ -5,20 +5,14 @@
enable
-
-
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/src/Core/Service/Service.Emails/EmailService.cs b/src/Core/Service/Service.Emails/EmailService.cs
new file mode 100644
index 0000000..7532769
--- /dev/null
+++ b/src/Core/Service/Service.Emails/EmailService.cs
@@ -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
+ );
+ }
+}
diff --git a/src/Core/Service/Service.Emails/Service.Emails.csproj b/src/Core/Service/Service.Emails/Service.Emails.csproj
new file mode 100644
index 0000000..2cdcb2a
--- /dev/null
+++ b/src/Core/Service/Service.Emails/Service.Emails.csproj
@@ -0,0 +1,13 @@
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
diff --git a/src/Core/Service/Service.UserManagement/Service.UserManagement.csproj b/src/Core/Service/Service.UserManagement/Service.UserManagement.csproj
index b26eb31..173c0b8 100644
--- a/src/Core/Service/Service.UserManagement/Service.UserManagement.csproj
+++ b/src/Core/Service/Service.UserManagement/Service.UserManagement.csproj
@@ -1,15 +1,12 @@
+
+ net10.0
+ enable
+ enable
+
-
- net10.0
- enable
- enable
-
-
-
-
-
-
-
+
+
+
+
diff --git a/src/Core/Service/Service.UserManagement/User/IUserService.cs b/src/Core/Service/Service.UserManagement/User/IUserService.cs
index ab66525..75b30e5 100644
--- a/src/Core/Service/Service.UserManagement/User/IUserService.cs
+++ b/src/Core/Service/Service.UserManagement/User/IUserService.cs
@@ -4,7 +4,10 @@ namespace Service.UserManagement.User;
public interface IUserService
{
- Task> GetAllAsync(int? limit = null, int? offset = null);
+ Task> GetAllAsync(
+ int? limit = null,
+ int? offset = null
+ );
Task GetByIdAsync(Guid id);
Task UpdateAsync(UserAccount userAccount);
diff --git a/src/Core/Service/Service.UserManagement/User/UserService.cs b/src/Core/Service/Service.UserManagement/User/UserService.cs
index 43a7816..a767477 100644
--- a/src/Core/Service/Service.UserManagement/User/UserService.cs
+++ b/src/Core/Service/Service.UserManagement/User/UserService.cs
@@ -6,7 +6,10 @@ namespace Service.UserManagement.User;
public class UserService(IUserAccountRepository repository) : IUserService
{
- public async Task> GetAllAsync(int? limit = null, int? offset = null)
+ public async Task> GetAllAsync(
+ int? limit = null,
+ int? offset = null
+ )
{
return await repository.GetAllAsync(limit, offset);
}