From 77bb1f67337a9e474f262bd556a9773efc94f960 Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Sat, 31 Jan 2026 11:34:55 -0500 Subject: [PATCH] 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);