From 954e224c34be96bb4bc08d27188bbb1ad4d9af14 Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Sat, 31 Jan 2026 15:41:00 -0500 Subject: [PATCH] update tests --- .../API.Core/Controllers/AuthController.cs | 15 ++- src/Core/API/API.Specs/Features/Auth.feature | 32 ++++- src/Core/API/API.Specs/Steps/ApiSteps.cs | 114 ++++++++++++++---- .../Service.Core/Services/AuthService.cs | 32 ++--- .../Service.Core/Services/IAuthService.cs | 2 +- 5 files changed, 140 insertions(+), 55 deletions(-) diff --git a/src/Core/API/API.Core/Controllers/AuthController.cs b/src/Core/API/API.Core/Controllers/AuthController.cs index aaf22db..516bfbe 100644 --- a/src/Core/API/API.Core/Controllers/AuthController.cs +++ b/src/Core/API/API.Core/Controllers/AuthController.cs @@ -1,3 +1,4 @@ +using System.Net; using DataAccessLayer.Entities; using Microsoft.AspNetCore.Mvc; using ServiceCore.Services; @@ -17,7 +18,13 @@ namespace WebAPI.Controllers string Password ); - public record LoginRequest(string UsernameOrEmail, string Password); + public record LoginRequest + { + public string Username { get; init; } = default!; + public string Password { get; init; } = default!; + } + + private record ResponseBody(string Message, object? Payload); [HttpPost("register")] public async Task> Register([FromBody] RegisterRequest req) @@ -39,15 +46,15 @@ namespace WebAPI.Controllers [HttpPost("login")] public async Task Login([FromBody] LoginRequest req) { - var userAccount = await auth.LoginAsync(req.UsernameOrEmail, req.Password); + var userAccount = await auth.LoginAsync(req.Username, req.Password); if (userAccount is null) { - return Unauthorized(); + return Unauthorized(new ResponseBody("Invalid username or password.", null)); } var jwt = jwtService.GenerateJwt(userAccount.UserAccountId, userAccount.Username, userAccount.DateOfBirth); - return Ok(new { AccessToken = jwt, Message = "Logged in successfully." }); + return Ok(new ResponseBody("Logged in successfully.", new { AccessToken = jwt })); } } } \ No newline at end of file diff --git a/src/Core/API/API.Specs/Features/Auth.feature b/src/Core/API/API.Specs/Features/Auth.feature index c8d9145..b85109f 100644 --- a/src/Core/API/API.Specs/Features/Auth.feature +++ b/src/Core/API/API.Specs/Features/Auth.feature @@ -1,12 +1,34 @@ Feature: User Login -As a registered user -I want to log in to my account -So that I receive an authentication token to access authenticated routes + As a registered user + I want to log in to my account + So that I receive an authentication token to access authenticated routes Scenario: Successful login with valid credentials Given the API is running And I have an existing account - And I submit a login request with a valid username and password + When I submit a login request with a username and password Then the response has HTTP status 200 And the response JSON should have "message" equal "Logged in successfully." - And the response JSON should have a valid access token. \ No newline at end of file + And the response JSON should have an access token + + Scenario: Login fails with invalid credentials + Given the API is running + And I do not have an existing account + When I submit a login request with a username and password + Then the response has HTTP status 401 + And the response JSON should have "message" equal "Invalid username or password." + + Scenario: Login fails when required missing username + Given the API is running + When I submit a login request with a missing username + Then the response has HTTP status 400 + + Scenario: Login fails when required missing password + Given the API is running + When I submit a login request with a missing password + Then the response has HTTP status 400 + + Scenario: Login fails when both username and password are missing + Given the API is running + When I submit a login request with both username and password missing + Then the response has HTTP status 400 \ No newline at end of file diff --git a/src/Core/API/API.Specs/Steps/ApiSteps.cs b/src/Core/API/API.Specs/Steps/ApiSteps.cs index 760dce0..5446997 100644 --- a/src/Core/API/API.Specs/Steps/ApiSteps.cs +++ b/src/Core/API/API.Specs/Steps/ApiSteps.cs @@ -1,7 +1,9 @@ using System.Net; using System.Net.Http.Json; +using System.Text.Json; using Reqnroll; using FluentAssertions; +using System.IdentityModel.Tokens.Jwt; namespace API.Specs.Steps; @@ -10,9 +12,11 @@ public class ApiSteps { private readonly TestApiFactory _factory = new(); private HttpClient? _client; - private HttpResponseMessage? _response; - private (string username, string password) testUser; + private HttpResponseMessage? _response; + private string? _responseBody; + + private (string username, string password) _testUser; [Given("the API is running")] public void GivenTheApiIsRunning() @@ -20,7 +24,7 @@ public class ApiSteps _client = _factory.CreateClient(); } - + [Then("the response status code should be {int}")] public void ThenStatusCodeShouldBe(int expected) { @@ -29,17 +33,27 @@ public class ApiSteps } [Then("the response JSON should have {string} equal {string}")] - public async Task ThenResponseJsonShouldHaveFieldEqual(string field, string expected) + public void ThenTheResponseJsonShouldHaveStringEqualString(string field, string expected) { _response.Should().NotBeNull(); - var dict = await _response!.Content.ReadFromJsonAsync>(); - dict.Should().NotBeNull(); - dict!.TryGetValue(field, out var value).Should().BeTrue(); - (value?.ToString()).Should().Be(expected); + _responseBody.Should().NotBeNull(); + + 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); + } + + value.ValueKind.Should().Be(JsonValueKind.String, "Expected field '{0}' to be a string", field); + value.GetString().Should().Be(expected); } [When("I send an HTTP request {string} to {string} with body:")] - public async Task WhenISendAnHttpRequestToWithBody(string method, string url, string jsonBody) + public async Task WhenISendAnHttpRequestStringToStringWithBody(string method, string url, string jsonBody) { _client.Should().NotBeNull(); @@ -49,7 +63,10 @@ public class ApiSteps Content = new StringContent(jsonBody, System.Text.Encoding.UTF8, "application/json") }; + _response = await _client!.SendAsync(requestMessage); + + _responseBody = await _response.Content.ReadAsStringAsync(); } [When("I send an HTTP request {string} to {string}")] @@ -57,6 +74,7 @@ public class ApiSteps { var requestMessage = new HttpRequestMessage(new HttpMethod(method), url); _response = await _client!.SendAsync(requestMessage); + _responseBody = await _response.Content.ReadAsStringAsync(); } [Then("the response has HTTP status {int}")] @@ -70,30 +88,78 @@ public class ApiSteps [Given("I have an existing account")] public void GivenIHaveAnExistingAccount() { - testUser = ("test.user", "password"); + _testUser = ("test.user", "password"); } - [Given("I submit a login request with a valid username and password")] - public async Task GivenISubmitALoginRequestWithAValidUsernameAndPassword() + [When("I submit a login request with a username and password")] + public async Task WhenISubmitALoginRequestWithAUsernameAndPassword() { - await WhenISendAnHttpRequestToWithBody("POST", "/api/v1/account/login", $@" + await WhenISendAnHttpRequestStringToStringWithBody("POST", "/api/auth/login", $@" {{ - ""username"": ""{testUser.username}"", - ""password"": ""{testUser.password}"" + ""username"": ""{_testUser.username}"", + ""password"": ""{_testUser.password}"" }}"); } - [Then("the response JSON should have a valid access token.")] - public async Task ThenTheResponseJsonShouldHaveAValidAccessToken() + + [Then("the response JSON should have an access token")] + public void ThenTheResponseJsonShouldHaveAnAccessToken() { - var dict = await _response!.Content.ReadFromJsonAsync>(); - dict.Should().NotBeNull(); - - dict!.TryGetValue("AccessToken", out var value).Should().BeTrue(); + _response.Should().NotBeNull(); + _responseBody.Should().NotBeNull(); - var messageStr = value!.ToString(); - - Console.WriteLine(messageStr); + using var doc = JsonDocument.Parse(_responseBody!); + var root = doc.RootElement; + JsonElement tokenElem; + var hasToken = root.TryGetProperty("accessToken", out tokenElem) + || root.TryGetProperty("AccessToken", out tokenElem); + if (!hasToken) + { + if (root.TryGetProperty("payload", out var payloadElem) && payloadElem.ValueKind == JsonValueKind.Object) + { + 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'"); + + var token = tokenElem.GetString(); + + // @todo validate the token + + token.Should().NotBeNullOrEmpty(); + } + + + [Given("I do not have an existing account")] + public void GivenIDoNotHaveAnExistingAccount() + { + _testUser = ("Failing", "User"); + } + + [When("I submit a login request with a missing username")] + public async Task WhenISubmitALoginRequestWithAMissingUsername() + { + await WhenISendAnHttpRequestStringToStringWithBody("POST", "/api/auth/login", $@" + {{ + ""password"": ""test"" + }}"); + } + + [When("I submit a login request with a missing password")] + public async Task WhenISubmitALoginRequestWithAMissingPassword() + { + await WhenISendAnHttpRequestStringToStringWithBody("POST", "/api/auth/login", $@" + {{ + ""username"": ""test"" + }}"); + } + + [When("I submit a login request with both username and password missing")] + public async Task WhenISubmitALoginRequestWithBothUsernameAndPasswordMissing() + { + await WhenISendAnHttpRequestStringToStringWithBody("POST", "/api/auth/login", "{}"); } } \ No newline at end of file diff --git a/src/Core/Service/Service.Core/Services/AuthService.cs b/src/Core/Service/Service.Core/Services/AuthService.cs index 82fcba1..1c40d6d 100644 --- a/src/Core/Service/Service.Core/Services/AuthService.cs +++ b/src/Core/Service/Service.Core/Services/AuthService.cs @@ -7,33 +7,23 @@ namespace ServiceCore.Services { public async Task RegisterAsync(UserAccount userAccount, string password) { - throw new NotImplementedException(); + throw new NotImplementedException(); } - public async Task LoginAsync(string usernameOrEmail, string password) + public async Task LoginAsync(string username, string password) { - // Attempt lookup by username, then email - var user = await userRepo.GetByUsernameAsync(usernameOrEmail) - ?? await userRepo.GetByEmailAsync(usernameOrEmail); + // Attempt lookup by username + var user = await userRepo.GetByUsernameAsync(username); + // the user was not found - if (user is null) - { - return null; - } - - // they don't have an active credential + if (user is null) return null; + // @todo handle expired passwords var activeCred = await credRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId); - if (activeCred is null) - { - return null; - } - - if (!PasswordHasher.Verify(password, activeCred.Hash)) - { - return null; - } - + + if (activeCred is null) return null; + if (!PasswordHasher.Verify(password, activeCred.Hash)) return null; + return user; } diff --git a/src/Core/Service/Service.Core/Services/IAuthService.cs b/src/Core/Service/Service.Core/Services/IAuthService.cs index b63048d..ca410a8 100644 --- a/src/Core/Service/Service.Core/Services/IAuthService.cs +++ b/src/Core/Service/Service.Core/Services/IAuthService.cs @@ -5,6 +5,6 @@ namespace ServiceCore.Services public interface IAuthService { Task RegisterAsync(UserAccount userAccount, string password); - Task LoginAsync(string usernameOrEmail, string password); + Task LoginAsync(string username, string password); } } \ No newline at end of file