mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-02-16 10:42:08 +00:00
auth updates
This commit is contained in:
11
src/Core/API/API.Specs/Features/Auth.feature
Normal file
11
src/Core/API/API.Specs/Features/Auth.feature
Normal 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
|
||||
@@ -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."
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
@@ -146,4 +126,4 @@ namespace DataAccessLayer.Repositories.UserAccount
|
||||
command.Parameters.Add(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user