Add user registration bdd tests

This commit is contained in:
Aaron Po
2026-02-10 23:09:00 -05:00
parent 656981003b
commit 8a4b833943
6 changed files with 315 additions and 282 deletions

View File

@@ -1,69 +1,75 @@
Feature: User Registration Feature: User Registration
As a new user As a new user
I want to register an account I want to register an account
So that I can log in and access authenticated routes So that I can log in and access authenticated routes
@Ignore
Scenario: Successful registration with valid details Scenario: Successful registration with valid details
Given the API is running Given the API is running
When I submit a registration request with values: When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password | | Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | newuser@example.com | 1990-01-01 | Password1! | | newuser | New | User | newuser@example.com | 1990-01-01 | Password1! |
Then the response has HTTP status 201 Then the response has HTTP status 201
And the response JSON should have "message" equal "User registered successfully." And the response JSON should have "message" equal "User registered successfully."
And the response JSON should have an access token And the response JSON should have an access token
@Ignore
Scenario: Registration fails with existing username @Ignore
Given the API is running Scenario: Registration fails with existing username
And I have an existing account with username "existinguser" Given the API is running
When I submit a registration request with values: And I have an existing account with username "existinguser"
| Username | FirstName | LastName | Email | DateOfBirth | Password | When I submit a registration request with values:
| existinguser | Existing | User | existing@example.com | 1990-01-01 | Password1! | | Username | FirstName | LastName | Email | DateOfBirth | Password |
Then the response has HTTP status 409 | existinguser | Existing | User | existing@example.com | 1990-01-01 | Password1! |
And the response JSON should have "message" equal "Username already exists." Then the response has HTTP status 409
@Ignore And the response JSON should have "message" equal "Username already exists."
Scenario: Registration fails with existing email
Given the API is running @Ignore
And I have an existing account with email "existing@example.com" Scenario: Registration fails with existing email
When I submit a registration request with values: Given the API is running
| Username | FirstName | LastName | Email | DateOfBirth | Password | And I have an existing account with email "existing@example.com"
| newuser | New | User | existing@example.com | 1990-01-01 | Password1! | When I submit a registration request with values:
Then the response has HTTP status 409 | Username | FirstName | LastName | Email | DateOfBirth | Password |
And the response JSON should have "message" equal "Email already in use." | newuser | New | User | existing@example.com | 1990-01-01 | Password1! |
@Ignore Then the response has HTTP status 409
Scenario: Registration fails with missing required fields And the response JSON should have "message" equal "Email already in use."
Given the API is running
When I submit a registration request with values: @Ignore
| Username | FirstName | LastName | Email | DateOfBirth | Password | Scenario: Registration fails with missing required fields
| | New | User | | | Password1! | Given the API is running
Then the response has HTTP status 400 When I submit a registration request with values:
And the response JSON should have "message" equal "Username is required." | Username | FirstName | LastName | Email | DateOfBirth | Password |
@Ignore | | New | User | | | Password1! |
Scenario: Registration fails with invalid email format Then the response has HTTP status 400
Given the API is running And the response JSON should have "message" equal "Username is required."
When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password | @Ignore
| newuser | New | User | invalidemail | 1990-01-01 | Password1! | Scenario: Registration fails with invalid email format
Then the response has HTTP status 400 Given the API is running
And the response JSON should have "message" equal "Invalid email format." When I submit a registration request with values:
@Ignore | Username | FirstName | LastName | Email | DateOfBirth | Password |
Scenario: Registration fails with weak password | newuser | New | User | invalidemail | 1990-01-01 | Password1! |
Given the API is running Then the response has HTTP status 400
When I submit a registration request with values: And the response JSON should have "message" equal "Invalid email format."
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | newuser@example.com | 1990-01-01 | weakpass | @Ignore
Then the response has HTTP status 400 Scenario: Registration fails with weak password
And the response JSON should have "message" equal "Password does not meet complexity requirements." Given the API is running
@Ignore When I submit a registration request with values:
Scenario: Cannot register a user younger than 19 years of age (regulatory requirement) | Username | FirstName | LastName | Email | DateOfBirth | Password |
Given the API is running | newuser | New | User | newuser@example.com | 1990-01-01 | weakpass |
When I submit a registration request with values: Then the response has HTTP status 400
| Username | FirstName | LastName | Email | DateOfBirth | Password | And the response JSON should have "message" equal "Password does not meet complexity requirements."
| younguser | Young | User | younguser@example.com | | Password1! |
Then the response has HTTP status 400 @Ignore
And the response JSON should have "message" equal "You must be at least 19 years old to register." Scenario: Cannot register a user younger than 19 years of age (regulatory requirement)
@Ignore Given the API is running
Scenario: Registration endpoint only accepts POST requests When I submit a registration request with values:
Given the API is running | Username | FirstName | LastName | Email | DateOfBirth | Password |
When I submit a registration request using a GET request | younguser | Young | User | younguser@example.com | | Password1! |
Then the response has HTTP status 404 Then the response has HTTP status 400
And the response JSON should have "message" equal "Not Found." And the response JSON should have "message" equal "You must be at least 19 years old to register."
Scenario: Registration endpoint only accepts POST requests
Given the API is running
When I submit a registration request using a GET request
Then the response has HTTP status 404
And the response JSON should have "message" equal "Not Found."

View File

@@ -157,4 +157,64 @@ public class AuthSteps(ScenarioContext scenario)
scenario[ResponseKey] = response; scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; scenario[ResponseBodyKey] = responseBody;
} }
[When("I submit a registration request with values:")]
public async Task WhenISubmitARegistrationRequestWithValues(Table table)
{
var client = GetClient();
var row = table.Rows[0];
var registrationData = new
{
username = row.TryGetValue("Username", out var value) ? value : null,
firstName = row.TryGetValue("FirstName", out var value1) ? value1 : null,
lastName = row.TryGetValue("LastName", out var value2) ? value2 : null,
email = row.TryGetValue("Email", out var value3) ? value3 : null,
dateOfBirth = row.ContainsKey("DateOfBirth") && !string.IsNullOrEmpty(row["DateOfBirth"])
? row["DateOfBirth"]
: null,
password = row.ContainsKey("Password") ? row["Password"] : null
};
var body = JsonSerializer.Serialize(registrationData);
var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/register")
{
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;
}
[Given("I have an existing account with username {string}")]
public void GivenIHaveAnExistingAccountWithUsername(string username)
{
}
[Given("I have an existing account with email {string}")]
public void GivenIHaveAnExistingAccountWithEmail(string email)
{
}
[When("I submit a registration request using a GET request")]
public async Task WhenISubmitARegistrationRequestUsingAGetRequest()
{
var client = GetClient();
var requestMessage = new HttpRequestMessage(HttpMethod.Get, "/api/auth/register")
{
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;
}
} }

View File

@@ -22,10 +22,10 @@ namespace Repository.Core.Repositories.Auth
{ {
await using var connection = await CreateConnection(); await using var connection = await CreateConnection();
await using var command = connection.CreateCommand(); await using var command = connection.CreateCommand();
command.CommandText = "USP_RegisterUser"; command.CommandText = "USP_RegisterUser";
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
// Input parameters
AddParameter(command, "@Username", username); AddParameter(command, "@Username", username);
AddParameter(command, "@FirstName", firstName); AddParameter(command, "@FirstName", firstName);
AddParameter(command, "@LastName", lastName); AddParameter(command, "@LastName", lastName);
@@ -33,11 +33,9 @@ namespace Repository.Core.Repositories.Auth
AddParameter(command, "@DateOfBirth", dateOfBirth); AddParameter(command, "@DateOfBirth", dateOfBirth);
AddParameter(command, "@Hash", passwordHash); AddParameter(command, "@Hash", passwordHash);
// Execute and retrieve the generated UserAccountId from result set
var result = await command.ExecuteScalarAsync(); var result = await command.ExecuteScalarAsync();
var userAccountId = result != null ? (Guid)result : Guid.Empty; var userAccountId = result != null ? (Guid)result : Guid.Empty;
// Return the newly created user account
return new Entities.UserAccount return new Entities.UserAccount
{ {
UserAccountId = userAccountId, UserAccountId = userAccountId,
@@ -104,12 +102,6 @@ namespace Repository.Core.Repositories.Auth
await command.ExecuteNonQueryAsync(); await command.ExecuteNonQueryAsync();
} }
public async Task InvalidateCredentialsByUserAccountIdAsync(Guid userAccountId)
{
throw new NotImplementedException("InvalidateCredentialsByUserAccountIdAsync");
}
/// <summary> /// <summary>
/// Maps a data reader row to a UserAccount entity. /// Maps a data reader row to a UserAccount entity.
/// </summary> /// </summary>
@@ -172,4 +164,4 @@ namespace Repository.Core.Repositories.Auth
command.Parameters.Add(p); command.Parameters.Add(p);
} }
} }
} }

View File

@@ -55,12 +55,5 @@ namespace Repository.Core.Repositories.Auth
/// <param name="userAccountId">ID of the user account</param> /// <param name="userAccountId">ID of the user account</param>
/// <param name="newPasswordHash">New hashed password</param> /// <param name="newPasswordHash">New hashed password</param>
Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash); Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash);
/// <summary>
/// Invalidates all credentials for a user account (e.g., for logout or security purposes).
/// Uses stored procedure: USP_InvalidateUserCredential
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
Task InvalidateCredentialsByUserAccountIdAsync(Guid userAccountId);
} }
} }

View File

@@ -8,225 +8,212 @@ namespace Repository.Tests.Auth;
public class AuthRepositoryTest public class AuthRepositoryTest
{ {
private static AuthRepository CreateRepo(MockDbConnection conn) private static AuthRepository CreateRepo(MockDbConnection conn)
=> new(new TestConnectionFactory(conn)); => new(new TestConnectionFactory(conn));
[Fact] [Fact]
public async Task RegisterUserAsync_CreatesUserWithCredential_ReturnsUserAccount() public async Task RegisterUserAsync_CreatesUserWithCredential_ReturnsUserAccount()
{ {
var expectedUserId = Guid.NewGuid(); var expectedUserId = Guid.NewGuid();
var conn = new MockDbConnection(); var conn = new MockDbConnection();
conn.Mocks conn.Mocks
.When(cmd => cmd.CommandText == "USP_RegisterUser") .When(cmd => cmd.CommandText == "USP_RegisterUser")
.ReturnsTable(MockTable.WithColumns(("UserAccountId", typeof(Guid))) .ReturnsTable(MockTable.WithColumns(("UserAccountId", typeof(Guid)))
.AddRow(expectedUserId)); .AddRow(expectedUserId));
var repo = CreateRepo(conn); var repo = CreateRepo(conn);
var result = await repo.RegisterUserAsync( var result = await repo.RegisterUserAsync(
username: "testuser", username: "testuser",
firstName: "Test", firstName: "Test",
lastName: "User", lastName: "User",
email: "test@example.com", email: "test@example.com",
dateOfBirth: new DateTime(1990, 1, 1), dateOfBirth: new DateTime(1990, 1, 1),
passwordHash: "hashedpassword123" passwordHash: "hashedpassword123"
); );
result.Should().NotBeNull(); result.Should().NotBeNull();
result.UserAccountId.Should().Be(expectedUserId); result.UserAccountId.Should().Be(expectedUserId);
result.Username.Should().Be("testuser"); result.Username.Should().Be("testuser");
result.FirstName.Should().Be("Test"); result.FirstName.Should().Be("Test");
result.LastName.Should().Be("User"); result.LastName.Should().Be("User");
result.Email.Should().Be("test@example.com"); result.Email.Should().Be("test@example.com");
result.DateOfBirth.Should().Be(new DateTime(1990, 1, 1)); result.DateOfBirth.Should().Be(new DateTime(1990, 1, 1));
} }
[Fact] [Fact]
public async Task GetUserByEmailAsync_ReturnsUser_WhenExists() public async Task GetUserByEmailAsync_ReturnsUser_WhenExists()
{ {
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
var conn = new MockDbConnection(); var conn = new MockDbConnection();
conn.Mocks conn.Mocks
.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail") .When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
.ReturnsTable(MockTable.WithColumns( .ReturnsTable(MockTable.WithColumns(
("UserAccountId", typeof(Guid)), ("UserAccountId", typeof(Guid)),
("Username", typeof(string)), ("Username", typeof(string)),
("FirstName", typeof(string)), ("FirstName", typeof(string)),
("LastName", typeof(string)), ("LastName", typeof(string)),
("Email", typeof(string)), ("Email", typeof(string)),
("CreatedAt", typeof(DateTime)), ("CreatedAt", typeof(DateTime)),
("UpdatedAt", typeof(DateTime?)), ("UpdatedAt", typeof(DateTime?)),
("DateOfBirth", typeof(DateTime)), ("DateOfBirth", typeof(DateTime)),
("Timer", typeof(byte[])) ("Timer", typeof(byte[]))
).AddRow( ).AddRow(
userId, userId,
"emailuser", "emailuser",
"Email", "Email",
"User", "User",
"emailuser@example.com", "emailuser@example.com",
DateTime.UtcNow, DateTime.UtcNow,
null, null,
new DateTime(1990, 5, 15), new DateTime(1990, 5, 15),
null null
)); ));
var repo = CreateRepo(conn); var repo = CreateRepo(conn);
var result = await repo.GetUserByEmailAsync("emailuser@example.com"); var result = await repo.GetUserByEmailAsync("emailuser@example.com");
result.Should().NotBeNull(); result.Should().NotBeNull();
result!.UserAccountId.Should().Be(userId); result!.UserAccountId.Should().Be(userId);
result.Username.Should().Be("emailuser"); result.Username.Should().Be("emailuser");
result.Email.Should().Be("emailuser@example.com"); result.Email.Should().Be("emailuser@example.com");
result.FirstName.Should().Be("Email"); result.FirstName.Should().Be("Email");
result.LastName.Should().Be("User"); result.LastName.Should().Be("User");
} }
[Fact] [Fact]
public async Task GetUserByEmailAsync_ReturnsNull_WhenNotExists() public async Task GetUserByEmailAsync_ReturnsNull_WhenNotExists()
{ {
var conn = new MockDbConnection(); var conn = new MockDbConnection();
conn.Mocks conn.Mocks
.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail") .When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
.ReturnsTable(MockTable.Empty()); .ReturnsTable(MockTable.Empty());
var repo = CreateRepo(conn); var repo = CreateRepo(conn);
var result = await repo.GetUserByEmailAsync("nonexistent@example.com"); var result = await repo.GetUserByEmailAsync("nonexistent@example.com");
result.Should().BeNull(); result.Should().BeNull();
} }
[Fact] [Fact]
public async Task GetUserByUsernameAsync_ReturnsUser_WhenExists() public async Task GetUserByUsernameAsync_ReturnsUser_WhenExists()
{ {
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
var conn = new MockDbConnection(); var conn = new MockDbConnection();
conn.Mocks conn.Mocks
.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername") .When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername")
.ReturnsTable(MockTable.WithColumns( .ReturnsTable(MockTable.WithColumns(
("UserAccountId", typeof(Guid)), ("UserAccountId", typeof(Guid)),
("Username", typeof(string)), ("Username", typeof(string)),
("FirstName", typeof(string)), ("FirstName", typeof(string)),
("LastName", typeof(string)), ("LastName", typeof(string)),
("Email", typeof(string)), ("Email", typeof(string)),
("CreatedAt", typeof(DateTime)), ("CreatedAt", typeof(DateTime)),
("UpdatedAt", typeof(DateTime?)), ("UpdatedAt", typeof(DateTime?)),
("DateOfBirth", typeof(DateTime)), ("DateOfBirth", typeof(DateTime)),
("Timer", typeof(byte[])) ("Timer", typeof(byte[]))
).AddRow( ).AddRow(
userId, userId,
"usernameuser", "usernameuser",
"Username", "Username",
"User", "User",
"username@example.com", "username@example.com",
DateTime.UtcNow, DateTime.UtcNow,
null, null,
new DateTime(1985, 8, 20), new DateTime(1985, 8, 20),
null null
)); ));
var repo = CreateRepo(conn); var repo = CreateRepo(conn);
var result = await repo.GetUserByUsernameAsync("usernameuser"); var result = await repo.GetUserByUsernameAsync("usernameuser");
result.Should().NotBeNull(); result.Should().NotBeNull();
result!.UserAccountId.Should().Be(userId); result!.UserAccountId.Should().Be(userId);
result.Username.Should().Be("usernameuser"); result.Username.Should().Be("usernameuser");
result.Email.Should().Be("username@example.com"); result.Email.Should().Be("username@example.com");
} }
[Fact] [Fact]
public async Task GetUserByUsernameAsync_ReturnsNull_WhenNotExists() public async Task GetUserByUsernameAsync_ReturnsNull_WhenNotExists()
{ {
var conn = new MockDbConnection(); var conn = new MockDbConnection();
conn.Mocks conn.Mocks
.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername") .When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername")
.ReturnsTable(MockTable.Empty()); .ReturnsTable(MockTable.Empty());
var repo = CreateRepo(conn); var repo = CreateRepo(conn);
var result = await repo.GetUserByUsernameAsync("nonexistent"); var result = await repo.GetUserByUsernameAsync("nonexistent");
result.Should().BeNull(); result.Should().BeNull();
} }
[Fact] [Fact]
public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsCredential_WhenExists() public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsCredential_WhenExists()
{ {
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
var credentialId = Guid.NewGuid(); var credentialId = Guid.NewGuid();
var conn = new MockDbConnection(); var conn = new MockDbConnection();
conn.Mocks conn.Mocks
.When(cmd => cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId") .When(cmd => cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId")
.ReturnsTable(MockTable.WithColumns( .ReturnsTable(MockTable.WithColumns(
("UserCredentialId", typeof(Guid)), ("UserCredentialId", typeof(Guid)),
("UserAccountId", typeof(Guid)), ("UserAccountId", typeof(Guid)),
("Hash", typeof(string)), ("Hash", typeof(string)),
("CreatedAt", typeof(DateTime)), ("CreatedAt", typeof(DateTime)),
("Timer", typeof(byte[])) ("Timer", typeof(byte[]))
).AddRow( ).AddRow(
credentialId, credentialId,
userId, userId,
"hashed_password_value", "hashed_password_value",
DateTime.UtcNow, DateTime.UtcNow,
null null
)); ));
var repo = CreateRepo(conn); var repo = CreateRepo(conn);
var result = await repo.GetActiveCredentialByUserAccountIdAsync(userId); var result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
result.Should().NotBeNull(); result.Should().NotBeNull();
result!.UserCredentialId.Should().Be(credentialId); result!.UserCredentialId.Should().Be(credentialId);
result.UserAccountId.Should().Be(userId); result.UserAccountId.Should().Be(userId);
result.Hash.Should().Be("hashed_password_value"); result.Hash.Should().Be("hashed_password_value");
} }
[Fact] [Fact]
public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsNull_WhenNotExists() public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsNull_WhenNotExists()
{ {
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
var conn = new MockDbConnection(); var conn = new MockDbConnection();
conn.Mocks conn.Mocks
.When(cmd => cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId") .When(cmd => cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId")
.ReturnsTable(MockTable.Empty()); .ReturnsTable(MockTable.Empty());
var repo = CreateRepo(conn); var repo = CreateRepo(conn);
var result = await repo.GetActiveCredentialByUserAccountIdAsync(userId); var result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
result.Should().BeNull(); result.Should().BeNull();
} }
[Fact] [Fact]
public async Task RotateCredentialAsync_ExecutesSuccessfully() public async Task RotateCredentialAsync_ExecutesSuccessfully()
{ {
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
var newPasswordHash = "new_hashed_password"; var newPasswordHash = "new_hashed_password";
var conn = new MockDbConnection(); var conn = new MockDbConnection();
conn.Mocks conn.Mocks
.When(cmd => cmd.CommandText == "USP_RotateUserCredential") .When(cmd => cmd.CommandText == "USP_RotateUserCredential")
.ReturnsScalar(1); .ReturnsScalar(1);
var repo = CreateRepo(conn); var repo = CreateRepo(conn);
// Should not throw // Should not throw
var act = async () => await repo.RotateCredentialAsync(userId, newPasswordHash); var act = async () => await repo.RotateCredentialAsync(userId, newPasswordHash);
await act.Should().NotThrowAsync(); await act.Should().NotThrowAsync();
} }
[Fact]
public async Task InvalidateCredentialsByUserAccountIdAsync_ExecutesSuccessfully()
{
var userId = Guid.NewGuid();
var conn = new MockDbConnection();
var repo = CreateRepo(conn);
// Should complete without error
var act = async () => await repo.InvalidateCredentialsByUserAccountIdAsync(userId);
await act.Should().NotThrowAsync();
}
} }

View File

@@ -9,13 +9,13 @@ public class AuthService(
IPasswordService passwordService IPasswordService passwordService
) : IAuthService ) : IAuthService
{ {
public async Task<UserAccount?> RegisterAsync(UserAccount userAccount, string password) public async Task<UserAccount> RegisterAsync(UserAccount userAccount, string password)
{ {
// Check if user already exists // Check if user already exists
var user = await authRepo.GetUserByUsernameAsync(userAccount.Username); var user = await authRepo.GetUserByUsernameAsync(userAccount.Username);
if (user is not null) if (user is not null)
{ {
return null; return null!;
} }
// password hashing // password hashing
@@ -45,9 +45,4 @@ public class AuthService(
if (activeCred is null) return null; if (activeCred is null) return null;
return !passwordService.Verify(password, activeCred.Hash) ? null : user; return !passwordService.Verify(password, activeCred.Hash) ? null : user;
} }
}
public async Task InvalidateAsync(Guid userAccountId)
{
await authRepo.InvalidateCredentialsByUserAccountIdAsync(userAccountId);
}
}