From 97c093c4bcfe095836d92c1ccaef291a0bdfe258 Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Thu, 29 Jan 2026 18:13:34 -0500 Subject: [PATCH 1/5] Update namespace organization in service layer --- .../API.Core/Controllers/AuthController.cs | 18 ++++++--- .../API.Core/Controllers/UserController.cs | 2 +- src/Core/API/API.Core/Program.cs | 3 +- .../UserCredentialRepository.cs | 6 +-- .../Service/Service.Core/Service.Core.csproj | 2 +- .../Service.Core/Services/AuthService.cs | 30 ++++++++++----- .../Service.Core/Services/IAuthService.cs | 7 ++-- .../Service.Core/Services/IJwtService.cs | 6 +++ .../Service.Core/Services/IUserService.cs | 4 +- .../Service.Core/Services/JwtService.cs | 38 +++++++++++++++++++ .../Service.Core/Services/PasswordHasher.cs | 2 +- .../Service.Core/Services/UserService.cs | 3 +- 12 files changed, 92 insertions(+), 29 deletions(-) create mode 100644 src/Core/Service/Service.Core/Services/IJwtService.cs create mode 100644 src/Core/Service/Service.Core/Services/JwtService.cs diff --git a/src/Core/API/API.Core/Controllers/AuthController.cs b/src/Core/API/API.Core/Controllers/AuthController.cs index 18668d6..aaf22db 100644 --- a/src/Core/API/API.Core/Controllers/AuthController.cs +++ b/src/Core/API/API.Core/Controllers/AuthController.cs @@ -1,12 +1,12 @@ -using BusinessLayer.Services; using DataAccessLayer.Entities; using Microsoft.AspNetCore.Mvc; +using ServiceCore.Services; namespace WebAPI.Controllers { [ApiController] [Route("api/[controller]")] - public class AuthController(IAuthService auth) : ControllerBase + public class AuthController(IAuthService auth, IJwtService jwtService) : ControllerBase { public record RegisterRequest( string Username, @@ -39,9 +39,15 @@ namespace WebAPI.Controllers [HttpPost("login")] public async Task Login([FromBody] LoginRequest req) { - var ok = await auth.LoginAsync(req.UsernameOrEmail, req.Password); - if (!ok) return Unauthorized(); - return Ok(new { success = true }); + var userAccount = await auth.LoginAsync(req.UsernameOrEmail, req.Password); + if (userAccount is null) + { + return Unauthorized(); + } + + var jwt = jwtService.GenerateJwt(userAccount.UserAccountId, userAccount.Username, userAccount.DateOfBirth); + + return Ok(new { AccessToken = jwt, Message = "Logged in successfully." }); } } -} +} \ No newline at end of file diff --git a/src/Core/API/API.Core/Controllers/UserController.cs b/src/Core/API/API.Core/Controllers/UserController.cs index fb1d867..ce2b11a 100644 --- a/src/Core/API/API.Core/Controllers/UserController.cs +++ b/src/Core/API/API.Core/Controllers/UserController.cs @@ -1,6 +1,6 @@ -using BusinessLayer.Services; using DataAccessLayer.Entities; using Microsoft.AspNetCore.Mvc; +using ServiceCore.Services; namespace WebAPI.Controllers { diff --git a/src/Core/API/API.Core/Program.cs b/src/Core/API/API.Core/Program.cs index 98fb89f..48ca639 100644 --- a/src/Core/API/API.Core/Program.cs +++ b/src/Core/API/API.Core/Program.cs @@ -1,7 +1,7 @@ -using BusinessLayer.Services; using DataAccessLayer.Repositories.UserAccount; using DataAccessLayer.Repositories.UserCredential; using DataAccessLayer.Sql; +using ServiceCore.Services; var builder = WebApplication.CreateBuilder(args); @@ -16,6 +16,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); var app = builder.Build(); app.UseSwagger(); diff --git a/src/Core/Repository/Repository.Core/Repositories/UserCredential/UserCredentialRepository.cs b/src/Core/Repository/Repository.Core/Repositories/UserCredential/UserCredentialRepository.cs index e27e03b..b5aca86 100644 --- a/src/Core/Repository/Repository.Core/Repositories/UserCredential/UserCredentialRepository.cs +++ b/src/Core/Repository/Repository.Core/Repositories/UserCredential/UserCredentialRepository.cs @@ -14,7 +14,7 @@ namespace DataAccessLayer.Repositories.UserCredential command.CommandText = "USP_RotateUserCredential"; command.CommandType = CommandType.StoredProcedure; - AddParameter(command, "@UserAccountId", userAccountId); + AddParameter(command, "@UserAccountId_", userAccountId); AddParameter(command, "@Hash", credential.Hash); await command.ExecuteNonQueryAsync(); @@ -27,7 +27,7 @@ namespace DataAccessLayer.Repositories.UserCredential command.CommandText = "USP_GetActiveUserCredentialByUserAccountId"; command.CommandType = CommandType.StoredProcedure; - AddParameter(command, "@UserAccountId", userAccountId); + AddParameter(command, "@UserAccountId_", userAccountId); await using var reader = await command.ExecuteReaderAsync(); return await reader.ReadAsync() ? MapToEntity(reader) : null; @@ -40,7 +40,7 @@ namespace DataAccessLayer.Repositories.UserCredential command.CommandText = "USP_InvalidateUserCredential"; command.CommandType = CommandType.StoredProcedure; - AddParameter(command, "@UserAccountId", userAccountId); + AddParameter(command, "@UserAccountId_", userAccountId); await command.ExecuteNonQueryAsync(); } diff --git a/src/Core/Service/Service.Core/Service.Core.csproj b/src/Core/Service/Service.Core/Service.Core.csproj index d80bd8b..9c34a8f 100644 --- a/src/Core/Service/Service.Core/Service.Core.csproj +++ b/src/Core/Service/Service.Core/Service.Core.csproj @@ -3,7 +3,7 @@ net10.0 enable enable - BusinessLayer + ServiceCore diff --git a/src/Core/Service/Service.Core/Services/AuthService.cs b/src/Core/Service/Service.Core/Services/AuthService.cs index 64aa5b0..72731d2 100644 --- a/src/Core/Service/Service.Core/Services/AuthService.cs +++ b/src/Core/Service/Service.Core/Services/AuthService.cs @@ -1,8 +1,7 @@ using DataAccessLayer.Entities; using DataAccessLayer.Repositories.UserAccount; -using DataAccessLayer.Repositories.UserCredential; -namespace BusinessLayer.Services +namespace ServiceCore.Services { public class AuthService(IUserAccountRepository userRepo, IUserCredentialRepository credRepo) : IAuthService { @@ -26,18 +25,31 @@ namespace BusinessLayer.Services return userAccount; } - public async Task LoginAsync(string usernameOrEmail, string password) + public async Task LoginAsync(string usernameOrEmail, string password) { // Attempt lookup by username, then email - var user = await userRepo.GetByUsernameAsync(usernameOrEmail) + var user = await userRepo.GetByUsernameAsync(usernameOrEmail) ?? await userRepo.GetByEmailAsync(usernameOrEmail); - - if (user is null) return false; - + // the user was not found + if (user is null) + { + return null; + } + + // they don't have an active credential + // @todo handle expired passwords var activeCred = await credRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId); - if (activeCred is null) return false; + if (activeCred is null) + { + return null; + } - return PasswordHasher.Verify(password, activeCred.Hash); + if (!PasswordHasher.Verify(password, activeCred.Hash)) + { + return null; + } + + return user; } public async Task InvalidateAsync(Guid userAccountId) diff --git a/src/Core/Service/Service.Core/Services/IAuthService.cs b/src/Core/Service/Service.Core/Services/IAuthService.cs index 490bc6c..b63048d 100644 --- a/src/Core/Service/Service.Core/Services/IAuthService.cs +++ b/src/Core/Service/Service.Core/Services/IAuthService.cs @@ -1,11 +1,10 @@ using DataAccessLayer.Entities; -namespace BusinessLayer.Services +namespace ServiceCore.Services { public interface IAuthService { Task RegisterAsync(UserAccount userAccount, string password); - Task LoginAsync(string usernameOrEmail, string password); - Task InvalidateAsync(Guid userAccountId); + Task LoginAsync(string usernameOrEmail, string password); } -} +} \ No newline at end of file diff --git a/src/Core/Service/Service.Core/Services/IJwtService.cs b/src/Core/Service/Service.Core/Services/IJwtService.cs new file mode 100644 index 0000000..0dfc6e5 --- /dev/null +++ b/src/Core/Service/Service.Core/Services/IJwtService.cs @@ -0,0 +1,6 @@ +namespace ServiceCore.Services; + +public interface IJwtService +{ + string GenerateJwt(Guid userId, string username, DateTime expiry); +} \ No newline at end of file diff --git a/src/Core/Service/Service.Core/Services/IUserService.cs b/src/Core/Service/Service.Core/Services/IUserService.cs index 193a1e3..e1cf55d 100644 --- a/src/Core/Service/Service.Core/Services/IUserService.cs +++ b/src/Core/Service/Service.Core/Services/IUserService.cs @@ -1,6 +1,8 @@ + + using DataAccessLayer.Entities; -namespace BusinessLayer.Services +namespace ServiceCore.Services { public interface IUserService { diff --git a/src/Core/Service/Service.Core/Services/JwtService.cs b/src/Core/Service/Service.Core/Services/JwtService.cs new file mode 100644 index 0000000..36e7bca --- /dev/null +++ b/src/Core/Service/Service.Core/Services/JwtService.cs @@ -0,0 +1,38 @@ +using System.Security.Claims; +using System.Text; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames; + +namespace ServiceCore.Services; +public class JwtService(IConfiguration config) : IJwtService +{ + private readonly string? _secret = config["Jwt:Secret"]; + + public string GenerateJwt(Guid userId, string username, DateTime expiry) + { + var handler = new JsonWebTokenHandler(); + + var key = Encoding.UTF8.GetBytes(_secret ?? throw new InvalidOperationException("secret not set")); + + // Base claims (always present) + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, userId.ToString()), + new(JwtRegisteredClaimNames.UniqueName, username), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) + }; + + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(claims), + Expires = expiry, + SigningCredentials = new SigningCredentials( + new SymmetricSecurityKey(key), + SecurityAlgorithms.HmacSha256) + }; + + return handler.CreateToken(tokenDescriptor); + } +} \ No newline at end of file diff --git a/src/Core/Service/Service.Core/Services/PasswordHasher.cs b/src/Core/Service/Service.Core/Services/PasswordHasher.cs index 68c2f94..f691076 100644 --- a/src/Core/Service/Service.Core/Services/PasswordHasher.cs +++ b/src/Core/Service/Service.Core/Services/PasswordHasher.cs @@ -2,7 +2,7 @@ using System.Security.Cryptography; using System.Text; using Konscious.Security.Cryptography; -namespace BusinessLayer.Services +namespace ServiceCore.Services { public static class PasswordHasher { diff --git a/src/Core/Service/Service.Core/Services/UserService.cs b/src/Core/Service/Service.Core/Services/UserService.cs index 1445f41..937351f 100644 --- a/src/Core/Service/Service.Core/Services/UserService.cs +++ b/src/Core/Service/Service.Core/Services/UserService.cs @@ -1,8 +1,7 @@ using DataAccessLayer.Entities; -using DataAccessLayer.Repositories; using DataAccessLayer.Repositories.UserAccount; -namespace BusinessLayer.Services +namespace ServiceCore.Services { public class UserService(IUserAccountRepository repository) : IUserService { From 77bb1f67337a9e474f262bd556a9773efc94f960 Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Sat, 31 Jan 2026 11:34:55 -0500 Subject: [PATCH 2/5] auth updates --- src/Core/API/API.Specs/Features/Auth.feature | 11 ++++ .../API/API.Specs/Features/NotFound.feature | 18 +++--- src/Core/API/API.Specs/Steps/ApiSteps.cs | 62 ++++++++++++++----- src/Core/Database/Database.Seed/UserSeeder.cs | 17 +++++ .../Repositories/Repository.cs | 6 -- .../UserAccount/IUserAccountRepository.cs | 1 - .../UserAccount/UserAccountRepository.cs | 30 ++------- .../UserCredentialRepository.cs | 17 +---- .../UserAccount/UserAccountRepository.test.cs | 61 +++++++----------- .../UserCredentialRepository.test.cs | 53 +--------------- .../Service.Core/Services/AuthService.cs | 17 +---- .../Service.Core/Services/IUserService.cs | 6 +- .../Service.Core/Services/JwtService.cs | 4 +- .../Service.Core/Services/UserService.cs | 7 +-- 14 files changed, 118 insertions(+), 192 deletions(-) create mode 100644 src/Core/API/API.Specs/Features/Auth.feature diff --git a/src/Core/API/API.Specs/Features/Auth.feature b/src/Core/API/API.Specs/Features/Auth.feature new file mode 100644 index 0000000..296733b --- /dev/null +++ b/src/Core/API/API.Specs/Features/Auth.feature @@ -0,0 +1,11 @@ +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 + 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 + Then the system successfully authenticates the user + And returns a valid access token + And the response has HTTP status 200 \ No newline at end of file diff --git a/src/Core/API/API.Specs/Features/NotFound.feature b/src/Core/API/API.Specs/Features/NotFound.feature index 68806c8..fcfc041 100644 --- a/src/Core/API/API.Specs/Features/NotFound.feature +++ b/src/Core/API/API.Specs/Features/NotFound.feature @@ -1,10 +1,10 @@ -Feature: NotFound API - As a client of the API - I want consistent 404 responses - So that consumers can handle missing routes +Feature: NotFound Responses +As a client of the API +I want consistent 404 responses +So that consumers can gracefully handle missing routes - Scenario: GET error 404 returns NotFound message - Given the API is running - When I GET "/error/404" - Then the response status code should be 404 - And the response JSON should have "message" equal "Route not found." + Scenario: GET request to an invalid route returns 404 + Given the API is running + When I send an HTTP request "GET" to "/invalid-route" + Then the response has HTTP status 404 + And the response JSON should have "message" equal "Route not found." \ 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 a7fb6b3..0bb7726 100644 --- a/src/Core/API/API.Specs/Steps/ApiSteps.cs +++ b/src/Core/API/API.Specs/Steps/ApiSteps.cs @@ -8,14 +8,14 @@ namespace API.Specs.Steps; [Binding] public class ApiSteps { - private readonly TestApiFactory _factory; + private readonly TestApiFactory _factory = new(); private HttpClient? _client; private HttpResponseMessage? _response; - public ApiSteps() - { - _factory = new TestApiFactory(); - } + private (string username, string password) testUser; + + + private [Given("the API is running")] public void GivenTheApiIsRunning() @@ -23,15 +23,6 @@ public class ApiSteps _client = _factory.CreateClient(); } - // No user service assumptions needed for 404 tests - - [When("I GET {string}")] - public async Task WhenIGet(string path) - { - _client.Should().NotBeNull("API client must be initialized"); - _response = await _client!.GetAsync(path); - } - [Then("the response status code should be {int}")] public void ThenStatusCodeShouldBe(int expected) { @@ -48,4 +39,45 @@ public class ApiSteps dict!.TryGetValue(field, out var value).Should().BeTrue(); (value?.ToString()).Should().Be(expected); } -} + + [When("I send an HTTP request {string} to {string} with body:")] + public async Task WhenISendAnHttpRequestToWithBody(string method, string url, string jsonBody) + { + _client.Should().NotBeNull(); + + var requestMessage = new HttpRequestMessage(new HttpMethod(method), url) + { + // Convert the string body into JSON content + Content = new StringContent(jsonBody, System.Text.Encoding.UTF8, "application/json") + }; + + _response = await _client!.SendAsync(requestMessage); + } + + [When("I send an HTTP request {string} to {string}")] + public async Task WhenISendAnHttpRequestTo(string method, string url) + { + var requestMessage = new HttpRequestMessage(new HttpMethod(method), url); + _response = await _client!.SendAsync(requestMessage); + } + + [Then("the response has HTTP status {int}")] + public void ThenTheResponseHasHttpStatus(int expectedCode) + { + _response.Should().NotBeNull("No response was received from the API"); + + ((int)_response!.StatusCode).Should().Be(expectedCode); + } + + [Given("I have an existing account")] + public void GivenIHaveAnExistingAccount() + { + testUser = ("test.user", "password"); + } + + [Given("I submit a login request with a valid username and password")] + public void GivenISubmitALoginRequestWithAValidUsernameAndPassword() + { + WhenISendAnHttpRequestToWithBody("POST", "/api/v1/account/login"); + } +} \ No newline at end of file diff --git a/src/Core/Database/Database.Seed/UserSeeder.cs b/src/Core/Database/Database.Seed/UserSeeder.cs index 437ad46..940fd71 100644 --- a/src/Core/Database/Database.Seed/UserSeeder.cs +++ b/src/Core/Database/Database.Seed/UserSeeder.cs @@ -126,7 +126,23 @@ namespace DBSeed int createdCredentials = 0; int createdVerifications = 0; + { + const string firstName = "Test"; + const string lastName = "User"; + const string email = "test.user@thebiergarten.app"; + var dob = new DateTime(1985, 03, 01); + var hash = GeneratePasswordHash("password"); + var userAccountId = await RegisterUserAsync( + connection, + $"{firstName}.{lastName}", + firstName, + lastName, + dob, + email, + hash + ); + } foreach (var (firstName, lastName) in SeedNames) { // prepare user fields @@ -142,6 +158,7 @@ namespace DBSeed ); string hash = GeneratePasswordHash(pwd); + // register the user (creates account + credential) var userAccountId = await RegisterUserAsync( connection, diff --git a/src/Core/Repository/Repository.Core/Repositories/Repository.cs b/src/Core/Repository/Repository.Core/Repositories/Repository.cs index 154c6d6..37a3664 100644 --- a/src/Core/Repository/Repository.Core/Repositories/Repository.cs +++ b/src/Core/Repository/Repository.Core/Repositories/Repository.cs @@ -13,12 +13,6 @@ namespace DataAccessLayer.Repositories return connection; } - public abstract Task AddAsync(T entity); - public abstract Task> GetAllAsync(int? limit, int? offset); - public abstract Task GetByIdAsync(Guid id); - public abstract Task UpdateAsync(T entity); - public abstract Task DeleteAsync(Guid id); - protected abstract T MapToEntity(DbDataReader reader); } } diff --git a/src/Core/Repository/Repository.Core/Repositories/UserAccount/IUserAccountRepository.cs b/src/Core/Repository/Repository.Core/Repositories/UserAccount/IUserAccountRepository.cs index 6fcd832..85913f7 100644 --- a/src/Core/Repository/Repository.Core/Repositories/UserAccount/IUserAccountRepository.cs +++ b/src/Core/Repository/Repository.Core/Repositories/UserAccount/IUserAccountRepository.cs @@ -4,7 +4,6 @@ namespace DataAccessLayer.Repositories.UserAccount { public interface IUserAccountRepository { - Task AddAsync(Entities.UserAccount userAccount); Task GetByIdAsync(Guid id); Task> GetAllAsync(int? limit, int? offset); Task UpdateAsync(Entities.UserAccount userAccount); diff --git a/src/Core/Repository/Repository.Core/Repositories/UserAccount/UserAccountRepository.cs b/src/Core/Repository/Repository.Core/Repositories/UserAccount/UserAccountRepository.cs index 4dab2dc..b0772fa 100644 --- a/src/Core/Repository/Repository.Core/Repositories/UserAccount/UserAccountRepository.cs +++ b/src/Core/Repository/Repository.Core/Repositories/UserAccount/UserAccountRepository.cs @@ -7,27 +7,7 @@ namespace DataAccessLayer.Repositories.UserAccount public class UserAccountRepository(ISqlConnectionFactory connectionFactory) : Repository(connectionFactory), IUserAccountRepository { - /** - * @todo update the create user account stored proc to add user credential creation in - * a single transaction, use that transaction instead. - */ - public override async Task AddAsync(Entities.UserAccount userAccount) - { - await using var connection = await CreateConnection(); - await using var command = connection.CreateCommand(); - command.CommandText = "usp_CreateUserAccount"; - command.CommandType = CommandType.StoredProcedure; - AddParameter(command, "@UserAccountId", userAccount.UserAccountId); - AddParameter(command, "@Username", userAccount.Username); - AddParameter(command, "@FirstName", userAccount.FirstName); - AddParameter(command, "@LastName", userAccount.LastName); - AddParameter(command, "@Email", userAccount.Email); - AddParameter(command, "@DateOfBirth", userAccount.DateOfBirth); - - await command.ExecuteNonQueryAsync(); - } - - public override async Task GetByIdAsync(Guid id) + public async Task GetByIdAsync(Guid id) { await using var connection = await CreateConnection(); await using var command = connection.CreateCommand(); @@ -40,7 +20,7 @@ namespace DataAccessLayer.Repositories.UserAccount return await reader.ReadAsync() ? MapToEntity(reader) : null; } - public override async Task> GetAllAsync(int? limit, int? offset) + public async Task> GetAllAsync(int? limit, int? offset) { await using var connection = await CreateConnection(); await using var command = connection.CreateCommand(); @@ -64,7 +44,7 @@ namespace DataAccessLayer.Repositories.UserAccount return users; } - public override async Task UpdateAsync(Entities.UserAccount userAccount) + public async Task UpdateAsync(Entities.UserAccount userAccount) { await using var connection = await CreateConnection(); await using var command = connection.CreateCommand(); @@ -81,7 +61,7 @@ namespace DataAccessLayer.Repositories.UserAccount await command.ExecuteNonQueryAsync(); } - public override async Task DeleteAsync(Guid id) + public async Task DeleteAsync(Guid id) { await using var connection = await CreateConnection(); await using var command = connection.CreateCommand(); @@ -146,4 +126,4 @@ namespace DataAccessLayer.Repositories.UserAccount command.Parameters.Add(p); } } -} +} \ No newline at end of file diff --git a/src/Core/Repository/Repository.Core/Repositories/UserCredential/UserCredentialRepository.cs b/src/Core/Repository/Repository.Core/Repositories/UserCredential/UserCredentialRepository.cs index b5aca86..e1c6d8a 100644 --- a/src/Core/Repository/Repository.Core/Repositories/UserCredential/UserCredentialRepository.cs +++ b/src/Core/Repository/Repository.Core/Repositories/UserCredential/UserCredentialRepository.cs @@ -27,7 +27,7 @@ namespace DataAccessLayer.Repositories.UserCredential command.CommandText = "USP_GetActiveUserCredentialByUserAccountId"; command.CommandType = CommandType.StoredProcedure; - AddParameter(command, "@UserAccountId_", userAccountId); + AddParameter(command, "@UserAccountId", userAccountId); await using var reader = await command.ExecuteReaderAsync(); return await reader.ReadAsync() ? MapToEntity(reader) : null; @@ -44,21 +44,6 @@ namespace DataAccessLayer.Repositories.UserCredential await command.ExecuteNonQueryAsync(); } - public override Task AddAsync(Entities.UserCredential entity) - => throw new NotSupportedException("Use RotateCredentialAsync for adding/rotating credentials."); - - public override Task> GetAllAsync(int? limit, int? offset) - => throw new NotSupportedException("Listing credentials is not supported."); - - public override Task GetByIdAsync(Guid id) - => throw new NotSupportedException("Fetching credential by ID is not supported."); - - public override Task UpdateAsync(Entities.UserCredential entity) - => throw new NotSupportedException("Use RotateCredentialAsync to update credentials."); - - public override Task DeleteAsync(Guid id) - => throw new NotSupportedException("Deleting a credential by ID is not supported."); - protected override Entities.UserCredential MapToEntity(DbDataReader reader) { var entity = new Entities.UserCredential diff --git a/src/Core/Repository/Repository.Tests/UserAccount/UserAccountRepository.test.cs b/src/Core/Repository/Repository.Tests/UserAccount/UserAccountRepository.test.cs index 925017a..dc6e8bd 100644 --- a/src/Core/Repository/Repository.Tests/UserAccount/UserAccountRepository.test.cs +++ b/src/Core/Repository/Repository.Tests/UserAccount/UserAccountRepository.test.cs @@ -27,9 +27,9 @@ public class UserAccountRepositoryTest ("DateOfBirth", typeof(DateTime)), ("Timer", typeof(byte[])) ).AddRow(Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), - "yerb","Aaron","Po","aaronpo@example.com", - new DateTime(2020,1,1), null, - new DateTime(1990,1,1), null)); + "yerb", "Aaron", "Po", "aaronpo@example.com", + new DateTime(2020, 1, 1), null, + new DateTime(1990, 1, 1), null)); var repo = CreateRepo(conn); var result = await repo.GetByIdAsync(Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")); @@ -45,18 +45,20 @@ public class UserAccountRepositoryTest var conn = new MockDbConnection(); conn.Mocks .When(cmd => cmd.CommandText == "usp_GetAllUserAccounts") - .ReturnsTable(MockTable.WithColumns( - ("UserAccountId", typeof(Guid)), - ("Username", typeof(string)), - ("FirstName", typeof(string)), - ("LastName", typeof(string)), - ("Email", typeof(string)), - ("CreatedAt", typeof(DateTime)), - ("UpdatedAt", typeof(DateTime?)), - ("DateOfBirth", typeof(DateTime)), - ("Timer", typeof(byte[])) - ).AddRow(Guid.NewGuid(), "a","A","A","a@example.com", DateTime.UtcNow, null, DateTime.UtcNow.Date, null) - .AddRow(Guid.NewGuid(), "b","B","B","b@example.com", DateTime.UtcNow, null, DateTime.UtcNow.Date, null)); + .ReturnsTable(MockTable.WithColumns( + ("UserAccountId", typeof(Guid)), + ("Username", typeof(string)), + ("FirstName", typeof(string)), + ("LastName", typeof(string)), + ("Email", typeof(string)), + ("CreatedAt", typeof(DateTime)), + ("UpdatedAt", typeof(DateTime?)), + ("DateOfBirth", typeof(DateTime)), + ("Timer", typeof(byte[])) + ).AddRow(Guid.NewGuid(), "a", "A", "A", "a@example.com", DateTime.UtcNow, null, DateTime.UtcNow.Date, + null) + .AddRow(Guid.NewGuid(), "b", "B", "B", "b@example.com", DateTime.UtcNow, null, DateTime.UtcNow.Date, + null)); var repo = CreateRepo(conn); var results = (await repo.GetAllAsync(null, null)).ToList(); @@ -64,27 +66,6 @@ public class UserAccountRepositoryTest results.Select(r => r.Username).Should().BeEquivalentTo(new[] { "a", "b" }); } - [Fact] - public async Task AddAsync_ExecutesStoredProcedure() - { - var conn = new MockDbConnection(); - conn.Mocks - .When(cmd => cmd.CommandText == "usp_CreateUserAccount") - .ReturnsScalar(1); - - var repo = CreateRepo(conn); - var user = new DataAccessLayer.Entities.UserAccount - { - UserAccountId = Guid.NewGuid(), - Username = "newuser", - FirstName = "New", - LastName = "User", - Email = "newuser@example.com", - DateOfBirth = new DateTime(1991,1,1) - }; - - await repo.AddAsync(user); - } [Fact] public async Task GetByUsername_ReturnsRow() @@ -102,7 +83,8 @@ public class UserAccountRepositoryTest ("UpdatedAt", typeof(DateTime?)), ("DateOfBirth", typeof(DateTime)), ("Timer", typeof(byte[])) - ).AddRow(Guid.NewGuid(), "lookupuser","L","U","lookup@example.com", DateTime.UtcNow, null, DateTime.UtcNow.Date, null)); + ).AddRow(Guid.NewGuid(), "lookupuser", "L", "U", "lookup@example.com", DateTime.UtcNow, null, + DateTime.UtcNow.Date, null)); var repo = CreateRepo(conn); var result = await repo.GetByUsernameAsync("lookupuser"); @@ -126,11 +108,12 @@ public class UserAccountRepositoryTest ("UpdatedAt", typeof(DateTime?)), ("DateOfBirth", typeof(DateTime)), ("Timer", typeof(byte[])) - ).AddRow(Guid.NewGuid(), "byemail","B","E","byemail@example.com", DateTime.UtcNow, null, DateTime.UtcNow.Date, null)); + ).AddRow(Guid.NewGuid(), "byemail", "B", "E", "byemail@example.com", DateTime.UtcNow, null, + DateTime.UtcNow.Date, null)); var repo = CreateRepo(conn); var result = await repo.GetByEmailAsync("byemail@example.com"); result.Should().NotBeNull(); result!.Username.Should().Be("byemail"); } -} +} \ No newline at end of file diff --git a/src/Core/Repository/Repository.Tests/UserCredential/UserCredentialRepository.test.cs b/src/Core/Repository/Repository.Tests/UserCredential/UserCredentialRepository.test.cs index 708b2a5..71d4beb 100644 --- a/src/Core/Repository/Repository.Tests/UserCredential/UserCredentialRepository.test.cs +++ b/src/Core/Repository/Repository.Tests/UserCredential/UserCredentialRepository.test.cs @@ -1,61 +1,11 @@ using Apps72.Dev.Data.DbMocker; using DataAccessLayer.Repositories.UserCredential; -using DataAccessLayer.Sql; -using FluentAssertions; -using Moq; using Repository.Tests.Database; namespace Repository.Tests.UserCredential; public class UserCredentialRepositoryTests { - private static UserCredentialRepository CreateRepo() - { - var factoryMock = new Mock(MockBehavior.Strict); - // NotSupported methods do not use the factory; keep strict to ensure no unexpected calls. - return new UserCredentialRepository(factoryMock.Object); - } - - [Fact] - public async Task AddAsync_ShouldThrow_NotSupported() - { - var repo = CreateRepo(); - var act = async () => await repo.AddAsync(new DataAccessLayer.Entities.UserCredential()); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task GetAllAsync_ShouldThrow_NotSupported() - { - var repo = CreateRepo(); - var act = async () => await repo.GetAllAsync(null, null); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task GetByIdAsync_ShouldThrow_NotSupported() - { - var repo = CreateRepo(); - var act = async () => await repo.GetByIdAsync(Guid.NewGuid()); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task UpdateAsync_ShouldThrow_NotSupported() - { - var repo = CreateRepo(); - var act = async () => await repo.UpdateAsync(new DataAccessLayer.Entities.UserCredential()); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task DeleteAsync_ShouldThrow_NotSupported() - { - var repo = CreateRepo(); - var act = async () => await repo.DeleteAsync(Guid.NewGuid()); - await act.Should().ThrowAsync(); - } - [Fact] public async Task RotateCredentialAsync_ExecutesWithoutError() { @@ -70,6 +20,5 @@ public class UserCredentialRepositoryTests Hash = "hashed_password" }; await repo.RotateCredentialAsync(Guid.NewGuid(), credential); - } -} +} \ 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 72731d2..82fcba1 100644 --- a/src/Core/Service/Service.Core/Services/AuthService.cs +++ b/src/Core/Service/Service.Core/Services/AuthService.cs @@ -7,22 +7,7 @@ namespace ServiceCore.Services { public async Task RegisterAsync(UserAccount userAccount, string password) { - if (userAccount.UserAccountId == Guid.Empty) - { - userAccount.UserAccountId = Guid.NewGuid(); - } - - await userRepo.AddAsync(userAccount); - - var credential = new UserCredential - { - UserAccountId = userAccount.UserAccountId, - Hash = PasswordHasher.Hash(password) - }; - - await credRepo.RotateCredentialAsync(userAccount.UserAccountId, credential); - - return userAccount; + throw new NotImplementedException(); } public async Task LoginAsync(string usernameOrEmail, string password) diff --git a/src/Core/Service/Service.Core/Services/IUserService.cs b/src/Core/Service/Service.Core/Services/IUserService.cs index e1cf55d..e94dda6 100644 --- a/src/Core/Service/Service.Core/Services/IUserService.cs +++ b/src/Core/Service/Service.Core/Services/IUserService.cs @@ -1,5 +1,3 @@ - - using DataAccessLayer.Entities; namespace ServiceCore.Services @@ -9,8 +7,6 @@ namespace ServiceCore.Services Task> GetAllAsync(int? limit = null, int? offset = null); Task GetByIdAsync(Guid id); - Task AddAsync(UserAccount userAccount); - Task UpdateAsync(UserAccount userAccount); } -} +} \ No newline at end of file diff --git a/src/Core/Service/Service.Core/Services/JwtService.cs b/src/Core/Service/Service.Core/Services/JwtService.cs index 36e7bca..c881e70 100644 --- a/src/Core/Service/Service.Core/Services/JwtService.cs +++ b/src/Core/Service/Service.Core/Services/JwtService.cs @@ -8,8 +8,8 @@ using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredCla namespace ServiceCore.Services; public class JwtService(IConfiguration config) : IJwtService { - private readonly string? _secret = config["Jwt:Secret"]; - + // private readonly string? _secret = config["Jwt:Secret"]; + private readonly string? _secret = "128490218jfklsdajfdsa90f8sd0fid0safasr31jl2k1j4AFSDR!@#$fdsafjdslajfl"; public string GenerateJwt(Guid userId, string username, DateTime expiry) { var handler = new JsonWebTokenHandler(); diff --git a/src/Core/Service/Service.Core/Services/UserService.cs b/src/Core/Service/Service.Core/Services/UserService.cs index 937351f..a0869f5 100644 --- a/src/Core/Service/Service.Core/Services/UserService.cs +++ b/src/Core/Service/Service.Core/Services/UserService.cs @@ -14,12 +14,7 @@ namespace ServiceCore.Services { return await repository.GetByIdAsync(id); } - - public async Task AddAsync(UserAccount userAccount) - { - await repository.AddAsync(userAccount); - } - + public async Task UpdateAsync(UserAccount userAccount) { await repository.UpdateAsync(userAccount); From 9474fb7811b9bba9cb4455f483f54122d9c5a167 Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Sat, 31 Jan 2026 13:54:02 -0500 Subject: [PATCH 3/5] add tests for login behaviour --- .../API.Core/Properties/launchSettings.json | 23 --------------- src/Core/API/API.Specs/Features/Auth.feature | 15 +++++----- src/Core/API/API.Specs/Steps/ApiSteps.cs | 28 +++++++++++++++---- 3 files changed, 30 insertions(+), 36 deletions(-) delete mode 100644 src/Core/API/API.Core/Properties/launchSettings.json diff --git a/src/Core/API/API.Core/Properties/launchSettings.json b/src/Core/API/API.Core/Properties/launchSettings.json deleted file mode 100644 index 5941a5e..0000000 --- a/src/Core/API/API.Core/Properties/launchSettings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "http://localhost:5069", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "https://localhost:7002;http://localhost:5069", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/src/Core/API/API.Specs/Features/Auth.feature b/src/Core/API/API.Specs/Features/Auth.feature index 296733b..c8d9145 100644 --- a/src/Core/API/API.Specs/Features/Auth.feature +++ b/src/Core/API/API.Specs/Features/Auth.feature @@ -1,11 +1,12 @@ 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 - Then the system successfully authenticates the user - And returns a valid access token - And the response has HTTP status 200 \ No newline at end of file + And I submit a login request with a valid 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 diff --git a/src/Core/API/API.Specs/Steps/ApiSteps.cs b/src/Core/API/API.Specs/Steps/ApiSteps.cs index 0bb7726..760dce0 100644 --- a/src/Core/API/API.Specs/Steps/ApiSteps.cs +++ b/src/Core/API/API.Specs/Steps/ApiSteps.cs @@ -14,15 +14,13 @@ public class ApiSteps private (string username, string password) testUser; - - private - [Given("the API is running")] public void GivenTheApiIsRunning() { _client = _factory.CreateClient(); } + [Then("the response status code should be {int}")] public void ThenStatusCodeShouldBe(int expected) { @@ -62,7 +60,7 @@ public class ApiSteps } [Then("the response has HTTP status {int}")] - public void ThenTheResponseHasHttpStatus(int expectedCode) + public void ThenTheResponseHasHttpStatusInt(int expectedCode) { _response.Should().NotBeNull("No response was received from the API"); @@ -76,8 +74,26 @@ public class ApiSteps } [Given("I submit a login request with a valid username and password")] - public void GivenISubmitALoginRequestWithAValidUsernameAndPassword() + public async Task GivenISubmitALoginRequestWithAValidUsernameAndPassword() { - WhenISendAnHttpRequestToWithBody("POST", "/api/v1/account/login"); + await WhenISendAnHttpRequestToWithBody("POST", "/api/v1/account/login", $@" + {{ + ""username"": ""{testUser.username}"", + ""password"": ""{testUser.password}"" + }}"); + } + + [Then("the response JSON should have a valid access token.")] + public async Task ThenTheResponseJsonShouldHaveAValidAccessToken() + { + var dict = await _response!.Content.ReadFromJsonAsync>(); + dict.Should().NotBeNull(); + + dict!.TryGetValue("AccessToken", out var value).Should().BeTrue(); + + var messageStr = value!.ToString(); + + Console.WriteLine(messageStr); + } } \ No newline at end of file From 954e224c34be96bb4bc08d27188bbb1ad4d9af14 Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Sat, 31 Jan 2026 15:41:00 -0500 Subject: [PATCH 4/5] 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 From ee53cc60d8ec5915fc3fb0c94cbec0b40c0b8cec Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Sun, 1 Feb 2026 12:33:24 -0500 Subject: [PATCH 5/5] Edit auth bdd tests --- src/Core/API/API.Specs/Features/Auth.feature | 13 +- .../API/API.Specs/Steps/ApiGeneralSteps.cs | 99 +++++++++++ src/Core/API/API.Specs/Steps/ApiSteps.cs | 165 ------------------ src/Core/API/API.Specs/Steps/AuthSteps.cs | 160 +++++++++++++++++ 4 files changed, 268 insertions(+), 169 deletions(-) create mode 100644 src/Core/API/API.Specs/Steps/ApiGeneralSteps.cs delete mode 100644 src/Core/API/API.Specs/Steps/ApiSteps.cs create mode 100644 src/Core/API/API.Specs/Steps/AuthSteps.cs diff --git a/src/Core/API/API.Specs/Features/Auth.feature b/src/Core/API/API.Specs/Features/Auth.feature index b85109f..0adad13 100644 --- a/src/Core/API/API.Specs/Features/Auth.feature +++ b/src/Core/API/API.Specs/Features/Auth.feature @@ -1,7 +1,7 @@ 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 @@ -31,4 +31,9 @@ Feature: User Login 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 + Then the response has HTTP status 400 + + Scenario: Login endpoint only accepts POST requests + Given the API is running + When I submit a login request using a GET request + Then the response has HTTP status 404 \ No newline at end of file diff --git a/src/Core/API/API.Specs/Steps/ApiGeneralSteps.cs b/src/Core/API/API.Specs/Steps/ApiGeneralSteps.cs new file mode 100644 index 0000000..eb8d411 --- /dev/null +++ b/src/Core/API/API.Specs/Steps/ApiGeneralSteps.cs @@ -0,0 +1,99 @@ +using System.Text.Json; +using Reqnroll; +using FluentAssertions; +using API.Specs; + +namespace API.Specs.Steps; + +[Binding] +public class ApiGeneralSteps(ScenarioContext scenario) +{ + private const string ClientKey = "client"; + private const string FactoryKey = "factory"; + private const string ResponseKey = "response"; + private const string ResponseBodyKey = "responseBody"; + + private HttpClient GetClient() + { + if (scenario.TryGetValue(ClientKey, out var client)) + { + return client; + } + + var factory = scenario.TryGetValue(FactoryKey, out var f) ? f : new TestApiFactory(); + scenario[FactoryKey] = factory; + + client = factory.CreateClient(); + scenario[ClientKey] = client; + return client; + } + + [Given("the API is running")] + public void GivenTheApiIsRunning() + { + GetClient(); + } + + [When("I send an HTTP request {string} to {string} with body:")] + 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") + }; + + var response = await client.SendAsync(requestMessage); + var responseBody = await response.Content.ReadAsStringAsync(); + + scenario[ResponseKey] = response; + scenario[ResponseBodyKey] = responseBody; + } + + [When("I send an HTTP request {string} to {string}")] + public async Task WhenISendAnHttpRequestStringToString(string method, string url) + { + var client = GetClient(); + var requestMessage = new HttpRequestMessage(new HttpMethod(method), url); + var response = await client.SendAsync(requestMessage); + var responseBody = await response.Content.ReadAsStringAsync(); + + scenario[ResponseKey] = response; + scenario[ResponseBodyKey] = responseBody; + } + + [Then("the response status code should be {int}")] + public void ThenTheResponseStatusCodeShouldBeInt(int expected) + { + scenario.TryGetValue(ResponseKey, out var response).Should().BeTrue(); + ((int)response!.StatusCode).Should().Be(expected); + } + + [Then("the response has HTTP status {int}")] + public void ThenTheResponseHasHttpStatusInt(int expectedCode) + { + scenario.TryGetValue(ResponseKey, out var response).Should().BeTrue("No response was received from the API"); + ((int)response!.StatusCode).Should().Be(expectedCode); + } + + [Then("the response JSON should have {string} equal {string}")] + public void ThenTheResponseJsonShouldHaveStringEqualString(string field, string expected) + { + scenario.TryGetValue(ResponseKey, out var response).Should().BeTrue(); + scenario.TryGetValue(ResponseBodyKey, out var responseBody).Should().BeTrue(); + + using var doc = JsonDocument.Parse(responseBody!); + var root = doc.RootElement; + + if (!root.TryGetProperty(field, out var value)) + { + root.TryGetProperty("payload", out var payloadElem).Should().BeTrue("Expected field '{0}' to be present either at the root or inside 'payload'", field); + payloadElem.ValueKind.Should().Be(JsonValueKind.Object, "payload must be an object"); + payloadElem.TryGetProperty(field, out value).Should().BeTrue("Expected field '{0}' to be present inside 'payload'", field); + } + + value.ValueKind.Should().Be(JsonValueKind.String, "Expected field '{0}' to be a string", field); + value.GetString().Should().Be(expected); + } +} diff --git a/src/Core/API/API.Specs/Steps/ApiSteps.cs b/src/Core/API/API.Specs/Steps/ApiSteps.cs deleted file mode 100644 index 5446997..0000000 --- a/src/Core/API/API.Specs/Steps/ApiSteps.cs +++ /dev/null @@ -1,165 +0,0 @@ -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; - -[Binding] -public class ApiSteps -{ - private readonly TestApiFactory _factory = new(); - private HttpClient? _client; - - private HttpResponseMessage? _response; - private string? _responseBody; - - private (string username, string password) _testUser; - - [Given("the API is running")] - public void GivenTheApiIsRunning() - { - _client = _factory.CreateClient(); - } - - - [Then("the response status code should be {int}")] - public void ThenStatusCodeShouldBe(int expected) - { - _response.Should().NotBeNull(); - ((int)_response!.StatusCode).Should().Be(expected); - } - - [Then("the response JSON should have {string} equal {string}")] - public void ThenTheResponseJsonShouldHaveStringEqualString(string field, string expected) - { - _response.Should().NotBeNull(); - _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 WhenISendAnHttpRequestStringToStringWithBody(string method, string url, string jsonBody) - { - _client.Should().NotBeNull(); - - var requestMessage = new HttpRequestMessage(new HttpMethod(method), url) - { - // Convert the string body into JSON content - 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}")] - public async Task WhenISendAnHttpRequestTo(string method, string url) - { - 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}")] - public void ThenTheResponseHasHttpStatusInt(int expectedCode) - { - _response.Should().NotBeNull("No response was received from the API"); - - ((int)_response!.StatusCode).Should().Be(expectedCode); - } - - [Given("I have an existing account")] - public void GivenIHaveAnExistingAccount() - { - _testUser = ("test.user", "password"); - } - - [When("I submit a login request with a username and password")] - public async Task WhenISubmitALoginRequestWithAUsernameAndPassword() - { - await WhenISendAnHttpRequestStringToStringWithBody("POST", "/api/auth/login", $@" - {{ - ""username"": ""{_testUser.username}"", - ""password"": ""{_testUser.password}"" - }}"); - } - - - [Then("the response JSON should have an access token")] - public void ThenTheResponseJsonShouldHaveAnAccessToken() - { - _response.Should().NotBeNull(); - _responseBody.Should().NotBeNull(); - - 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/API/API.Specs/Steps/AuthSteps.cs b/src/Core/API/API.Specs/Steps/AuthSteps.cs new file mode 100644 index 0000000..92ae4c7 --- /dev/null +++ b/src/Core/API/API.Specs/Steps/AuthSteps.cs @@ -0,0 +1,160 @@ +using System.Text.Json; +using Reqnroll; +using FluentAssertions; +using API.Specs; + +namespace API.Specs.Steps; + +[Binding] +public class AuthSteps(ScenarioContext scenario) +{ + private const string ClientKey = "client"; + private const string FactoryKey = "factory"; + private const string ResponseKey = "response"; + private const string ResponseBodyKey = "responseBody"; + private const string TestUserKey = "testUser"; + + private HttpClient GetClient() + { + if (scenario.TryGetValue(ClientKey, out var client)) + { + return client; + } + + var factory = scenario.TryGetValue(FactoryKey, out var f) ? f : new TestApiFactory(); + scenario[FactoryKey] = factory; + + client = factory.CreateClient(); + scenario[ClientKey] = client; + return client; + } + + [Given("I have an existing account")] + public void GivenIHaveAnExistingAccount() + { + scenario[TestUserKey] = ("test.user", "password"); + } + + [Given("I do not have an existing account")] + public void GivenIDoNotHaveAnExistingAccount() + { + scenario[TestUserKey] = ("Failing", "User"); + } + + [When("I submit a login request with a username and password")] + public async Task WhenISubmitALoginRequestWithAUsernameAndPassword() + { + var client = GetClient(); + 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") + { + Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") + }; + + var response = await client.SendAsync(requestMessage); + var responseBody = await response.Content.ReadAsStringAsync(); + + scenario[ResponseKey] = response; + scenario[ResponseBodyKey] = responseBody; + } + + [When("I submit a login request with a missing username")] + public async Task WhenISubmitALoginRequestWithAMissingUsername() + { + var client = GetClient(); + var body = JsonSerializer.Serialize(new { password = "test" }); + + var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login") + { + Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") + }; + + var response = await client.SendAsync(requestMessage); + var responseBody = await response.Content.ReadAsStringAsync(); + + scenario[ResponseKey] = response; + scenario[ResponseBodyKey] = responseBody; + } + + [When("I submit a login request with a missing password")] + public async Task WhenISubmitALoginRequestWithAMissingPassword() + { + var client = GetClient(); + var body = JsonSerializer.Serialize(new { username = "test" }); + + var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login") + { + Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") + }; + + var response = await client.SendAsync(requestMessage); + var responseBody = await response.Content.ReadAsStringAsync(); + + scenario[ResponseKey] = response; + scenario[ResponseBodyKey] = responseBody; + } + + [When("I submit a login request with both username and password missing")] + public async Task WhenISubmitALoginRequestWithBothUsernameAndPasswordMissing() + { + var client = GetClient(); + var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login") + { + Content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json") + }; + + var response = await client.SendAsync(requestMessage); + var responseBody = await response.Content.ReadAsStringAsync(); + + scenario[ResponseKey] = response; + scenario[ResponseBodyKey] = responseBody; + } + + [Then("the response JSON should have an access token")] + public void ThenTheResponseJsonShouldHaveAnAccessToken() + { + scenario.TryGetValue(ResponseKey, out var response).Should().BeTrue(); + scenario.TryGetValue(ResponseBodyKey, out var responseBody).Should().BeTrue(); + + 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) + { + 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(); + 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") + { + Content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json") + }; + + var response = await client.SendAsync(requestMessage); + var responseBody = await response.Content.ReadAsStringAsync(); + + scenario[ResponseKey] = response; + scenario[ResponseBodyKey] = responseBody; + } +} \ No newline at end of file