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
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
When I GET "/error/404"
Then the response status code should be 404
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."

View File

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

View File

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

View File

@@ -13,12 +13,6 @@ namespace DataAccessLayer.Repositories
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);
}
}

View File

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

View File

@@ -7,27 +7,7 @@ namespace DataAccessLayer.Repositories.UserAccount
public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
: Repository<Entities.UserAccount>(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<Entities.UserAccount?> GetByIdAsync(Guid id)
public async Task<Entities.UserAccount?> 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<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 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();

View File

@@ -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<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)
{
var entity = new Entities.UserCredential

View File

@@ -55,8 +55,10 @@ public class UserAccountRepositoryTest
("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));
).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,7 +108,8 @@ 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");

View File

@@ -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<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]
public async Task RotateCredentialAsync_ExecutesWithoutError()
{
@@ -70,6 +20,5 @@ public class UserCredentialRepositoryTests
Hash = "hashed_password"
};
await repo.RotateCredentialAsync(Guid.NewGuid(), credential);
}
}

View File

@@ -7,22 +7,7 @@ namespace ServiceCore.Services
{
public async Task<UserAccount> 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<UserAccount?> LoginAsync(string usernameOrEmail, string password)

View File

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

View File

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

View File

@@ -15,11 +15,6 @@ 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);