auth updates

This commit is contained in:
Aaron Po
2026-01-31 11:34:55 -05:00
parent 1af3d6f987
commit 77bb1f6733
14 changed files with 118 additions and 192 deletions

View File

@@ -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

View File

@@ -1,10 +1,10 @@
Feature: NotFound API Feature: NotFound Responses
As a client of the API As a client of the API
I want consistent 404 responses I want consistent 404 responses
So that consumers can handle missing routes So that consumers can gracefully handle missing routes
Scenario: GET error 404 returns NotFound message Scenario: GET request to an invalid route returns 404
Given the API is running Given the API is running
When I GET "/error/404" When I send an HTTP request "GET" to "/invalid-route"
Then the response status code should be 404 Then the response has HTTP status 404
And the response JSON should have "message" equal "Route not found." And the response JSON should have "message" equal "Route not found."

View File

@@ -8,14 +8,14 @@ namespace API.Specs.Steps;
[Binding] [Binding]
public class ApiSteps public class ApiSteps
{ {
private readonly TestApiFactory _factory; private readonly TestApiFactory _factory = new();
private HttpClient? _client; private HttpClient? _client;
private HttpResponseMessage? _response; private HttpResponseMessage? _response;
public ApiSteps() private (string username, string password) testUser;
{
_factory = new TestApiFactory();
} private
[Given("the API is running")] [Given("the API is running")]
public void GivenTheApiIsRunning() public void GivenTheApiIsRunning()
@@ -23,15 +23,6 @@ public class ApiSteps
_client = _factory.CreateClient(); _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}")] [Then("the response status code should be {int}")]
public void ThenStatusCodeShouldBe(int expected) public void ThenStatusCodeShouldBe(int expected)
{ {
@@ -48,4 +39,45 @@ public class ApiSteps
dict!.TryGetValue(field, out var value).Should().BeTrue(); dict!.TryGetValue(field, out var value).Should().BeTrue();
(value?.ToString()).Should().Be(expected); (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");
}
} }

View File

@@ -126,7 +126,23 @@ namespace DBSeed
int createdCredentials = 0; int createdCredentials = 0;
int createdVerifications = 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) foreach (var (firstName, lastName) in SeedNames)
{ {
// prepare user fields // prepare user fields
@@ -142,6 +158,7 @@ namespace DBSeed
); );
string hash = GeneratePasswordHash(pwd); string hash = GeneratePasswordHash(pwd);
// register the user (creates account + credential) // register the user (creates account + credential)
var userAccountId = await RegisterUserAsync( var userAccountId = await RegisterUserAsync(
connection, connection,

View File

@@ -13,12 +13,6 @@ namespace DataAccessLayer.Repositories
return connection; return connection;
} }
public abstract Task AddAsync(T entity);
public abstract Task<IEnumerable<T>> GetAllAsync(int? limit, int? offset);
public abstract Task<T?> GetByIdAsync(Guid id);
public abstract Task UpdateAsync(T entity);
public abstract Task DeleteAsync(Guid id);
protected abstract T MapToEntity(DbDataReader reader); protected abstract T MapToEntity(DbDataReader reader);
} }
} }

View File

@@ -4,7 +4,6 @@ namespace DataAccessLayer.Repositories.UserAccount
{ {
public interface IUserAccountRepository public interface IUserAccountRepository
{ {
Task AddAsync(Entities.UserAccount userAccount);
Task<Entities.UserAccount?> GetByIdAsync(Guid id); Task<Entities.UserAccount?> GetByIdAsync(Guid id);
Task<IEnumerable<Entities.UserAccount>> GetAllAsync(int? limit, int? offset); Task<IEnumerable<Entities.UserAccount>> GetAllAsync(int? limit, int? offset);
Task UpdateAsync(Entities.UserAccount userAccount); Task UpdateAsync(Entities.UserAccount userAccount);

View File

@@ -7,27 +7,7 @@ namespace DataAccessLayer.Repositories.UserAccount
public class UserAccountRepository(ISqlConnectionFactory connectionFactory) public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
: Repository<Entities.UserAccount>(connectionFactory), IUserAccountRepository : Repository<Entities.UserAccount>(connectionFactory), IUserAccountRepository
{ {
/** public async Task<Entities.UserAccount?> GetByIdAsync(Guid id)
* @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<Entities.UserAccount?> GetByIdAsync(Guid id)
{ {
await using var connection = await CreateConnection(); await using var connection = await CreateConnection();
await using var command = connection.CreateCommand(); await using var command = connection.CreateCommand();
@@ -40,7 +20,7 @@ namespace DataAccessLayer.Repositories.UserAccount
return await reader.ReadAsync() ? MapToEntity(reader) : null; return await reader.ReadAsync() ? MapToEntity(reader) : null;
} }
public override async Task<IEnumerable<Entities.UserAccount>> GetAllAsync(int? limit, int? offset) public async Task<IEnumerable<Entities.UserAccount>> GetAllAsync(int? limit, int? offset)
{ {
await using var connection = await CreateConnection(); await using var connection = await CreateConnection();
await using var command = connection.CreateCommand(); await using var command = connection.CreateCommand();
@@ -64,7 +44,7 @@ namespace DataAccessLayer.Repositories.UserAccount
return users; 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 connection = await CreateConnection();
await using var command = connection.CreateCommand(); await using var command = connection.CreateCommand();
@@ -81,7 +61,7 @@ namespace DataAccessLayer.Repositories.UserAccount
await command.ExecuteNonQueryAsync(); 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 connection = await CreateConnection();
await using var command = connection.CreateCommand(); await using var command = connection.CreateCommand();

View File

@@ -27,7 +27,7 @@ namespace DataAccessLayer.Repositories.UserCredential
command.CommandText = "USP_GetActiveUserCredentialByUserAccountId"; command.CommandText = "USP_GetActiveUserCredentialByUserAccountId";
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId_", userAccountId); AddParameter(command, "@UserAccountId", userAccountId);
await using var reader = await command.ExecuteReaderAsync(); await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null; return await reader.ReadAsync() ? MapToEntity(reader) : null;
@@ -44,21 +44,6 @@ namespace DataAccessLayer.Repositories.UserCredential
await command.ExecuteNonQueryAsync(); await command.ExecuteNonQueryAsync();
} }
public override Task AddAsync(Entities.UserCredential entity)
=> throw new NotSupportedException("Use RotateCredentialAsync for adding/rotating credentials.");
public override Task<IEnumerable<Entities.UserCredential>> GetAllAsync(int? limit, int? offset)
=> throw new NotSupportedException("Listing credentials is not supported.");
public override Task<Entities.UserCredential?> 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) protected override Entities.UserCredential MapToEntity(DbDataReader reader)
{ {
var entity = new Entities.UserCredential var entity = new Entities.UserCredential

View File

@@ -55,8 +55,10 @@ public class UserAccountRepositoryTest
("UpdatedAt", typeof(DateTime?)), ("UpdatedAt", typeof(DateTime?)),
("DateOfBirth", typeof(DateTime)), ("DateOfBirth", typeof(DateTime)),
("Timer", typeof(byte[])) ("Timer", typeof(byte[]))
).AddRow(Guid.NewGuid(), "a","A","A","a@example.com", DateTime.UtcNow, null, DateTime.UtcNow.Date, null) ).AddRow(Guid.NewGuid(), "a", "A", "A", "a@example.com", DateTime.UtcNow, null, DateTime.UtcNow.Date,
.AddRow(Guid.NewGuid(), "b","B","B","b@example.com", DateTime.UtcNow, null, DateTime.UtcNow.Date, null)); null)
.AddRow(Guid.NewGuid(), "b", "B", "B", "b@example.com", DateTime.UtcNow, null, DateTime.UtcNow.Date,
null));
var repo = CreateRepo(conn); var repo = CreateRepo(conn);
var results = (await repo.GetAllAsync(null, null)).ToList(); 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" }); 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] [Fact]
public async Task GetByUsername_ReturnsRow() public async Task GetByUsername_ReturnsRow()
@@ -102,7 +83,8 @@ public class UserAccountRepositoryTest
("UpdatedAt", typeof(DateTime?)), ("UpdatedAt", typeof(DateTime?)),
("DateOfBirth", typeof(DateTime)), ("DateOfBirth", typeof(DateTime)),
("Timer", typeof(byte[])) ("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 repo = CreateRepo(conn);
var result = await repo.GetByUsernameAsync("lookupuser"); var result = await repo.GetByUsernameAsync("lookupuser");
@@ -126,7 +108,8 @@ public class UserAccountRepositoryTest
("UpdatedAt", typeof(DateTime?)), ("UpdatedAt", typeof(DateTime?)),
("DateOfBirth", typeof(DateTime)), ("DateOfBirth", typeof(DateTime)),
("Timer", typeof(byte[])) ("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 repo = CreateRepo(conn);
var result = await repo.GetByEmailAsync("byemail@example.com"); var result = await repo.GetByEmailAsync("byemail@example.com");

View File

@@ -1,61 +1,11 @@
using Apps72.Dev.Data.DbMocker; using Apps72.Dev.Data.DbMocker;
using DataAccessLayer.Repositories.UserCredential; using DataAccessLayer.Repositories.UserCredential;
using DataAccessLayer.Sql;
using FluentAssertions;
using Moq;
using Repository.Tests.Database; using Repository.Tests.Database;
namespace Repository.Tests.UserCredential; namespace Repository.Tests.UserCredential;
public class UserCredentialRepositoryTests public class UserCredentialRepositoryTests
{ {
private static UserCredentialRepository CreateRepo()
{
var factoryMock = new Mock<ISqlConnectionFactory>(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<NotSupportedException>();
}
[Fact]
public async Task GetAllAsync_ShouldThrow_NotSupported()
{
var repo = CreateRepo();
var act = async () => await repo.GetAllAsync(null, null);
await act.Should().ThrowAsync<NotSupportedException>();
}
[Fact]
public async Task GetByIdAsync_ShouldThrow_NotSupported()
{
var repo = CreateRepo();
var act = async () => await repo.GetByIdAsync(Guid.NewGuid());
await act.Should().ThrowAsync<NotSupportedException>();
}
[Fact]
public async Task UpdateAsync_ShouldThrow_NotSupported()
{
var repo = CreateRepo();
var act = async () => await repo.UpdateAsync(new DataAccessLayer.Entities.UserCredential());
await act.Should().ThrowAsync<NotSupportedException>();
}
[Fact]
public async Task DeleteAsync_ShouldThrow_NotSupported()
{
var repo = CreateRepo();
var act = async () => await repo.DeleteAsync(Guid.NewGuid());
await act.Should().ThrowAsync<NotSupportedException>();
}
[Fact] [Fact]
public async Task RotateCredentialAsync_ExecutesWithoutError() public async Task RotateCredentialAsync_ExecutesWithoutError()
{ {
@@ -70,6 +20,5 @@ public class UserCredentialRepositoryTests
Hash = "hashed_password" Hash = "hashed_password"
}; };
await repo.RotateCredentialAsync(Guid.NewGuid(), credential); await repo.RotateCredentialAsync(Guid.NewGuid(), credential);
} }
} }

View File

@@ -7,22 +7,7 @@ namespace ServiceCore.Services
{ {
public async Task<UserAccount> RegisterAsync(UserAccount userAccount, string password) public async Task<UserAccount> RegisterAsync(UserAccount userAccount, string password)
{ {
if (userAccount.UserAccountId == Guid.Empty) throw new NotImplementedException();
{
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;
} }
public async Task<UserAccount?> LoginAsync(string usernameOrEmail, string password) public async Task<UserAccount?> LoginAsync(string usernameOrEmail, string password)

View File

@@ -1,5 +1,3 @@
using DataAccessLayer.Entities; using DataAccessLayer.Entities;
namespace ServiceCore.Services namespace ServiceCore.Services
@@ -9,8 +7,6 @@ namespace ServiceCore.Services
Task<IEnumerable<UserAccount>> GetAllAsync(int? limit = null, int? offset = null); Task<IEnumerable<UserAccount>> GetAllAsync(int? limit = null, int? offset = null);
Task<UserAccount?> GetByIdAsync(Guid id); Task<UserAccount?> GetByIdAsync(Guid id);
Task AddAsync(UserAccount userAccount);
Task UpdateAsync(UserAccount userAccount); Task UpdateAsync(UserAccount userAccount);
} }
} }

View File

@@ -8,8 +8,8 @@ using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredCla
namespace ServiceCore.Services; namespace ServiceCore.Services;
public class JwtService(IConfiguration config) : IJwtService 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) public string GenerateJwt(Guid userId, string username, DateTime expiry)
{ {
var handler = new JsonWebTokenHandler(); var handler = new JsonWebTokenHandler();

View File

@@ -15,11 +15,6 @@ namespace ServiceCore.Services
return await repository.GetByIdAsync(id); return await repository.GetByIdAsync(id);
} }
public async Task AddAsync(UserAccount userAccount)
{
await repository.AddAsync(userAccount);
}
public async Task UpdateAsync(UserAccount userAccount) public async Task UpdateAsync(UserAccount userAccount)
{ {
await repository.UpdateAsync(userAccount); await repository.UpdateAsync(userAccount);