Service refactor (#153)

* remove email out of register service

* Update auth service, move JWT handling out of controller

* add docker config for service auth test

* Update mock email system

* Format: ./src/Core/Service

* Refactor authentication payloads and services for registration and login processes

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

View File

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

View File

@@ -1,4 +1,19 @@
using Domain.Entities;
using Org.BouncyCastle.Asn1.Cms;
namespace API.Core.Contracts.Auth;
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
);

View File

@@ -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<LoginRequest>
{
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");
}
}

View File

@@ -17,38 +17,55 @@ public class RegisterRequestValidator : AbstractValidator<RegisterRequest>
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"
);
}
}

View File

@@ -2,11 +2,11 @@ namespace API.Core.Contracts.Common;
public record ResponseBody<T>
{
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; }
}

View File

@@ -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<ActionResult<UserAccount>> Register([FromBody] RegisterRequest req)
public async Task<ActionResult<UserAccount>> 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<AuthPayload>
var response = new ResponseBody<RegistrationPayload>
{
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<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);
var jwtExpiresAt = DateTime.UtcNow.AddHours(1);
var jwt = tokenInfrastructure.GenerateJwt(userAccount.UserAccountId, userAccount.Username, jwtExpiresAt);
return Ok(new ResponseBody<AuthPayload>
{
Message = "Logged in successfully.",
Payload = new AuthPayload(dto, jwt, DateTime.UtcNow, jwtExpiresAt)
});
return Ok(
new ResponseBody<LoginPayload>
{
Message = "Logged in successfully.",
Payload = new LoginPayload(
rtn.UserAccount.UserAccountId,
rtn.UserAccount.Username,
rtn.RefreshToken,
rtn.AccessToken
),
}
);
}
}
}

View File

@@ -9,7 +9,10 @@ namespace API.Core.Controllers
public class UserController(IUserService userService) : ControllerBase
{
[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);
return Ok(users);

View File

@@ -8,7 +8,8 @@ using Microsoft.AspNetCore.Mvc.Filters;
namespace API.Core;
public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger) : IExceptionFilter
public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
: IExceptionFilter
{
public void OnException(ExceptionContext context)
{
@@ -17,65 +18,78 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> 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;

View File

@@ -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<ISqlConnectionFactory, DefaultSqlConnectionFactory>();
builder.Services.AddSingleton<
ISqlConnectionFactory,
DefaultSqlConnectionFactory
>();
builder.Services.AddScoped<IUserAccountRepository, UserAccountRepository>();
builder.Services.AddScoped<IAuthRepository, AuthRepository>();
@@ -53,6 +56,7 @@ builder.Services.AddScoped<IAuthRepository, AuthRepository>();
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<ILoginService, LoginService>();
builder.Services.AddScoped<IRegisterService, RegisterService>();
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<ITokenInfrastructure, JwtInfrastructure>();
builder.Services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>();

View File

@@ -17,11 +17,17 @@
<!-- Reqnroll core, xUnit adapter and code-behind generator -->
<PackageReference Include="Reqnroll" Version="3.3.3" />
<PackageReference Include="Reqnroll.xUnit" Version="3.3.3" />
<PackageReference Include="Reqnroll.Tools.MsBuild.Generation" Version="3.3.3"
PrivateAssets="all" />
<PackageReference
Include="Reqnroll.Tools.MsBuild.Generation"
Version="3.3.3"
PrivateAssets="all"
/>
<!-- 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>
@@ -35,7 +41,6 @@
<ItemGroup>
<ProjectReference Include="..\API.Core\API.Core.csproj" />
<ProjectReference
Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
</ItemGroup>
</Project>

View File

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

View File

@@ -1,54 +1,38 @@
using Infrastructure.Email;
using Domain.Entities;
using Service.Emails;
namespace API.Specs.Mocks;
/// <summary>
/// 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 class MockEmailService : IEmailService
{
public List<SentEmail> SentEmails { get; } = new();
public List<RegistrationEmail> 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<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
});
public void Clear()
{
SentRegistrationEmails.Clear();
}
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; }
}
public class RegistrationEmail
{
public UserAccount UserAccount { get; init; } = null!;
public string ConfirmationToken { get; init; } = string.Empty;
public DateTime SentAt { get; init; }
}
}

View File

@@ -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<TestApiFactory>(FactoryKey, out var f) ? f : new TestApiFactory();
var factory = scenario.TryGetValue<TestApiFactory>(
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<HttpResponseMessage>(ResponseKey, out var response).Should().BeTrue();
scenario
.TryGetValue<HttpResponseMessage>(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<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);
}
[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.TryGetValue<string>(ResponseBodyKey, out var responseBody).Should().BeTrue();
scenario
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
.Should()
.BeTrue();
scenario
.TryGetValue<string>(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);
}
}

View File

@@ -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<TestApiFactory>(FactoryKey, out var f) ? f : new TestApiFactory();
var factory = scenario.TryGetValue<TestApiFactory>(
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<HttpResponseMessage>(ResponseKey, out var response).Should().BeTrue();
scenario.TryGetValue<string>(ResponseBodyKey, out var responseBody).Should().BeTrue();
scenario
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
.Should()
.BeTrue();
scenario
.TryGetValue<string>(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);

View File

@@ -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<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>();
});
}
}