mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-04-05 18:09:04 +00:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
|||||||
@@ -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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
68
src/Core/API/API.Specs/Mocks/MockEmailProvider.cs
Normal file
68
src/Core/API/API.Specs/Mocks/MockEmailProvider.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
21
src/Core/Service/Service.Auth.Tests/Dockerfile
Normal file
21
src/Core/Service/Service.Auth.Tests/Dockerfile
Normal 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"]
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
34
src/Core/Service/Service.Auth/ITokenService.cs
Normal file
34
src/Core/Service/Service.Auth/ITokenService.cs
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
41
src/Core/Service/Service.Emails/EmailService.cs
Normal file
41
src/Core/Service/Service.Emails/EmailService.cs
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/Core/Service/Service.Emails/Service.Emails.csproj
Normal file
13
src/Core/Service/Service.Emails/Service.Emails.csproj
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user