From fd544dbd34373877d10cacfdc5a5860a474083b9 Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Mon, 19 Jan 2026 22:57:24 -0500 Subject: [PATCH 1/5] Start stored procs for user credentials --- .../Controllers/NotFoundController.cs | 4 +-- API/API.Core/Program.cs | 1 + .../USP_DeleteUserCredential.sql | 0 .../USP_GetUserCredentialByUserAccountId.sql | 0 .../USP_UpdateUserCredential.sql | 0 .../Repositories/IUserAccountRepository.cs | 15 ----------- .../UserAccount/IUserAccountRepository.cs | 15 +++++++++++ .../UserAccountRepository.cs | 25 +++++++++---------- .../IUserCredentialRepository.cs | 11 ++++++++ .../UserAccountRepositoryTests.cs | 1 + Service/Service.Core/Services/UserService.cs | 1 + 11 files changed, 43 insertions(+), 30 deletions(-) create mode 100644 Database/Database.Core/scripts/03-crud/02-UserCredential/USP_DeleteUserCredential.sql create mode 100644 Database/Database.Core/scripts/03-crud/02-UserCredential/USP_GetUserCredentialByUserAccountId.sql create mode 100644 Database/Database.Core/scripts/03-crud/02-UserCredential/USP_UpdateUserCredential.sql delete mode 100644 Repository/Repository.Core/Repositories/IUserAccountRepository.cs create mode 100644 Repository/Repository.Core/Repositories/UserAccount/IUserAccountRepository.cs rename Repository/Repository.Core/Repositories/{ => UserAccount}/UserAccountRepository.cs (87%) create mode 100644 Repository/Repository.Core/Repositories/UserCredential/IUserCredentialRepository.cs diff --git a/API/API.Core/Controllers/NotFoundController.cs b/API/API.Core/Controllers/NotFoundController.cs index 857694d..52fb2aa 100644 --- a/API/API.Core/Controllers/NotFoundController.cs +++ b/API/API.Core/Controllers/NotFoundController.cs @@ -4,10 +4,10 @@ namespace WebAPI.Controllers { [ApiController] [ApiExplorerSettings(IgnoreApi = true)] - [Route("error")] // ← required + [Route("error")] // required public class NotFoundController : ControllerBase { - [HttpGet("404")] // ← required + [HttpGet("404")] //required public IActionResult Handle404() { return NotFound(new { message = "Route not found." }); diff --git a/API/API.Core/Program.cs b/API/API.Core/Program.cs index 81bab8e..6d36b50 100644 --- a/API/API.Core/Program.cs +++ b/API/API.Core/Program.cs @@ -1,5 +1,6 @@ using BusinessLayer.Services; using DataAccessLayer.Repositories; +using DataAccessLayer.Repositories.UserAccount; using DataAccessLayer.Sql; var builder = WebApplication.CreateBuilder(args); diff --git a/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_DeleteUserCredential.sql b/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_DeleteUserCredential.sql new file mode 100644 index 0000000..e69de29 diff --git a/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_GetUserCredentialByUserAccountId.sql b/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_GetUserCredentialByUserAccountId.sql new file mode 100644 index 0000000..e69de29 diff --git a/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_UpdateUserCredential.sql b/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_UpdateUserCredential.sql new file mode 100644 index 0000000..e69de29 diff --git a/Repository/Repository.Core/Repositories/IUserAccountRepository.cs b/Repository/Repository.Core/Repositories/IUserAccountRepository.cs deleted file mode 100644 index ca18801..0000000 --- a/Repository/Repository.Core/Repositories/IUserAccountRepository.cs +++ /dev/null @@ -1,15 +0,0 @@ -using DataAccessLayer.Entities; - -namespace DataAccessLayer.Repositories -{ - public interface IUserAccountRepository - { - Task Add(UserAccount userAccount); - Task GetById(Guid id); - Task> GetAll(int? limit, int? offset); - Task Update(UserAccount userAccount); - Task Delete(Guid id); - Task GetByUsername(string username); - Task GetByEmail(string email); - } -} diff --git a/Repository/Repository.Core/Repositories/UserAccount/IUserAccountRepository.cs b/Repository/Repository.Core/Repositories/UserAccount/IUserAccountRepository.cs new file mode 100644 index 0000000..dd5b76b --- /dev/null +++ b/Repository/Repository.Core/Repositories/UserAccount/IUserAccountRepository.cs @@ -0,0 +1,15 @@ + + +namespace DataAccessLayer.Repositories.UserAccount +{ + public interface IUserAccountRepository + { + Task Add(Entities.UserAccount userAccount); + Task GetById(Guid id); + Task> GetAll(int? limit, int? offset); + Task Update(Entities.UserAccount userAccount); + Task Delete(Guid id); + Task GetByUsername(string username); + Task GetByEmail(string email); + } +} diff --git a/Repository/Repository.Core/Repositories/UserAccountRepository.cs b/Repository/Repository.Core/Repositories/UserAccount/UserAccountRepository.cs similarity index 87% rename from Repository/Repository.Core/Repositories/UserAccountRepository.cs rename to Repository/Repository.Core/Repositories/UserAccount/UserAccountRepository.cs index 40eb278..80ee038 100644 --- a/Repository/Repository.Core/Repositories/UserAccountRepository.cs +++ b/Repository/Repository.Core/Repositories/UserAccount/UserAccountRepository.cs @@ -1,14 +1,13 @@ -using DataAccessLayer.Entities; +using System.Data; using DataAccessLayer.Sql; using Microsoft.Data.SqlClient; -using System.Data; -namespace DataAccessLayer.Repositories +namespace DataAccessLayer.Repositories.UserAccount { public class UserAccountRepository(ISqlConnectionFactory connectionFactory) - : Repository(connectionFactory), IUserAccountRepository + : Repository(connectionFactory), IUserAccountRepository { - public override async Task Add(UserAccount userAccount) + public override async Task Add(Entities.UserAccount userAccount) { await using var connection = await CreateConnection(); await using var command = new SqlCommand("usp_CreateUserAccount", connection); @@ -24,7 +23,7 @@ namespace DataAccessLayer.Repositories await command.ExecuteNonQueryAsync(); } - public override async Task GetById(Guid id) + public override async Task GetById(Guid id) { await using var connection = await CreateConnection(); await using var command = new SqlCommand("usp_GetUserAccountById", connection) @@ -38,7 +37,7 @@ namespace DataAccessLayer.Repositories return await reader.ReadAsync() ? MapToEntity(reader) : null; } - public override async Task> GetAll(int? limit, int? offset) + public override async Task> GetAll(int? limit, int? offset) { await using var connection = await CreateConnection(); await using var command = new SqlCommand("usp_GetAllUserAccounts", connection); @@ -51,7 +50,7 @@ namespace DataAccessLayer.Repositories command.Parameters.Add("@Offset", SqlDbType.Int).Value = offset.Value; await using var reader = await command.ExecuteReaderAsync(); - var users = new List(); + var users = new List(); while (await reader.ReadAsync()) { @@ -61,7 +60,7 @@ namespace DataAccessLayer.Repositories return users; } - public override async Task Update(UserAccount userAccount) + public override async Task Update(Entities.UserAccount userAccount) { await using var connection = await CreateConnection(); await using var command = new SqlCommand("usp_UpdateUserAccount", connection); @@ -87,7 +86,7 @@ namespace DataAccessLayer.Repositories await command.ExecuteNonQueryAsync(); } - public async Task GetByUsername(string username) + public async Task GetByUsername(string username) { await using var connection = await CreateConnection(); await using var command = new SqlCommand("usp_GetUserAccountByUsername", connection); @@ -99,7 +98,7 @@ namespace DataAccessLayer.Repositories return await reader.ReadAsync() ? MapToEntity(reader) : null; } - public async Task GetByEmail(string email) + public async Task GetByEmail(string email) { await using var connection = await CreateConnection(); await using var command = new SqlCommand("usp_GetUserAccountByEmail", connection); @@ -111,9 +110,9 @@ namespace DataAccessLayer.Repositories return await reader.ReadAsync() ? MapToEntity(reader) : null; } - protected override UserAccount MapToEntity(SqlDataReader reader) + protected override Entities.UserAccount MapToEntity(SqlDataReader reader) { - return new UserAccount + return new Entities.UserAccount { UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")), Username = reader.GetString(reader.GetOrdinal("Username")), diff --git a/Repository/Repository.Core/Repositories/UserCredential/IUserCredentialRepository.cs b/Repository/Repository.Core/Repositories/UserCredential/IUserCredentialRepository.cs new file mode 100644 index 0000000..9ac74c2 --- /dev/null +++ b/Repository/Repository.Core/Repositories/UserCredential/IUserCredentialRepository.cs @@ -0,0 +1,11 @@ +namespace DataAccessLayer.Repositories.UserCredential; + +public interface IUserCredentialRepository +{ + Task Add(Entities.UserCredential credential); + Task GetById(Guid userCredentialId); + Task GetByUserAccountId(Guid userAccountId); + Task> GetAll(int? limit, int? offset); + Task Update(Entities.UserCredential credential); + Task Delete(Guid userCredentialId); +} \ No newline at end of file diff --git a/Repository/Repository.Tests/UserAccountRepositoryTests.cs b/Repository/Repository.Tests/UserAccountRepositoryTests.cs index f5d9ddb..3cca273 100644 --- a/Repository/Repository.Tests/UserAccountRepositoryTests.cs +++ b/Repository/Repository.Tests/UserAccountRepositoryTests.cs @@ -1,6 +1,7 @@ using DataAccessLayer; using DataAccessLayer.Entities; using DataAccessLayer.Repositories; +using DataAccessLayer.Repositories.UserAccount; namespace DALTests { diff --git a/Service/Service.Core/Services/UserService.cs b/Service/Service.Core/Services/UserService.cs index 20a8e68..b4c4a85 100644 --- a/Service/Service.Core/Services/UserService.cs +++ b/Service/Service.Core/Services/UserService.cs @@ -1,5 +1,6 @@ using DataAccessLayer.Entities; using DataAccessLayer.Repositories; +using DataAccessLayer.Repositories.UserAccount; namespace BusinessLayer.Services { From 82db763951cc775780b0566940e055c3d6518365 Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Thu, 22 Jan 2026 11:14:23 -0500 Subject: [PATCH 2/5] Refactor repository methods to async and update credential logic --- .../scripts/01-schema/schema.sql | 11 +++-- .../USP_AddUserCredential.sql | 20 ++++---- .../USP_DeleteUserCredential.sql | 0 .../USP_GetUserCredentialByUserAccountId.sql | 18 +++++++ .../USP_UpdateUserCredential.sql | 0 .../Repositories/Repository.cs | 10 ++-- .../UserAccount/IUserAccountRepository.cs | 14 +++--- .../UserAccount/UserAccountRepository.cs | 14 +++--- .../IUserCredentialRepository.cs | 12 ++--- .../UserAccountRepositoryTests.cs | 48 +++++++++---------- Service/Service.Core/Services/UserService.cs | 4 +- 11 files changed, 84 insertions(+), 67 deletions(-) delete mode 100644 Database/Database.Core/scripts/03-crud/02-UserCredential/USP_DeleteUserCredential.sql delete mode 100644 Database/Database.Core/scripts/03-crud/02-UserCredential/USP_UpdateUserCredential.sql diff --git a/Database/Database.Core/scripts/01-schema/schema.sql b/Database/Database.Core/scripts/01-schema/schema.sql index 637832e..5a09894 100644 --- a/Database/Database.Core/scripts/01-schema/schema.sql +++ b/Database/Database.Core/scripts/01-schema/schema.sql @@ -164,7 +164,13 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted Hash NVARCHAR(MAX) NOT NULL, -- uses argon2 - Timer ROWVERSION, + IsRevoked BIT NOT NULL + CONSTRAINT DF_UserCredential_IsRevoked DEFAULT 0, + + RevokedAt DATETIME NULL, + + Timer ROWVERSION, + CONSTRAINT PK_UserCredential PRIMARY KEY (UserCredentialID), @@ -173,9 +179,6 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted FOREIGN KEY (UserAccountID) REFERENCES UserAccount(UserAccountID) ON DELETE CASCADE, - - CONSTRAINT AK_UserCredential_UserAccountID - UNIQUE (UserAccountID) ); CREATE NONCLUSTERED INDEX IX_UserCredential_UserAccount diff --git a/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_AddUserCredential.sql b/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_AddUserCredential.sql index bd9395e..f2b7b6a 100644 --- a/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_AddUserCredential.sql +++ b/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_AddUserCredential.sql @@ -1,6 +1,6 @@ -CREATE OR ALTER PROCEDURE dbo.USP_AddUserCredential( - @UserAccountId uniqueidentifier, - @Hash nvarchar(max) +CREATE OR ALTER PROCEDURE dbo.USP_AddUpdateUserCredential( + @UserAccountId UNIQUEIDENTIFIER, + @Hash NVARCHAR(MAX) ) AS BEGIN @@ -16,14 +16,14 @@ BEGIN ) THROW 50001, 'UserAccountID does not exist.', 1; - IF EXISTS ( - SELECT 1 - FROM dbo.UserCredential - WHERE UserAccountID = @UserAccountId - ) - THROW 50002, 'UserCredential for this UserAccountID already exists.', 1; - + -- invalidate old credentials + UPDATE dbo.UserCredential + SET IsRevoked = 1, + RevokedAt = GETDATE() + WHERE UserAccountId = @UserAccountId + AND IsRevoked = 0; + INSERT INTO dbo.UserCredential (UserAccountId, Hash) VALUES diff --git a/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_DeleteUserCredential.sql b/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_DeleteUserCredential.sql deleted file mode 100644 index e69de29..0000000 diff --git a/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_GetUserCredentialByUserAccountId.sql b/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_GetUserCredentialByUserAccountId.sql index e69de29..88a67a5 100644 --- a/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_GetUserCredentialByUserAccountId.sql +++ b/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_GetUserCredentialByUserAccountId.sql @@ -0,0 +1,18 @@ +CREATE OR ALTER PROCEDURE dbo.USP_GetUserCredentialByUserAccountId( + @UserAccountId UNIQUEIDENTIFIER +) +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + + SELECT + UserCredentialId, + UserAccountId, + Hash, + IsRevoked, + CreatedAt, + RevokedAt + FROM dbo.UserCredential + WHERE UserAccountId = @UserAccountId AND IsRevoked = 0; +END; \ No newline at end of file diff --git a/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_UpdateUserCredential.sql b/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_UpdateUserCredential.sql deleted file mode 100644 index e69de29..0000000 diff --git a/Repository/Repository.Core/Repositories/Repository.cs b/Repository/Repository.Core/Repositories/Repository.cs index 14cab10..dd3de00 100644 --- a/Repository/Repository.Core/Repositories/Repository.cs +++ b/Repository/Repository.Core/Repositories/Repository.cs @@ -13,11 +13,11 @@ namespace DataAccessLayer.Repositories return connection; } - public abstract Task Add(T entity); - public abstract Task> GetAll(int? limit, int? offset); - public abstract Task GetById(Guid id); - public abstract Task Update(T entity); - public abstract Task Delete(Guid id); + 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(SqlDataReader reader); } diff --git a/Repository/Repository.Core/Repositories/UserAccount/IUserAccountRepository.cs b/Repository/Repository.Core/Repositories/UserAccount/IUserAccountRepository.cs index dd5b76b..6fcd832 100644 --- a/Repository/Repository.Core/Repositories/UserAccount/IUserAccountRepository.cs +++ b/Repository/Repository.Core/Repositories/UserAccount/IUserAccountRepository.cs @@ -4,12 +4,12 @@ namespace DataAccessLayer.Repositories.UserAccount { public interface IUserAccountRepository { - Task Add(Entities.UserAccount userAccount); - Task GetById(Guid id); - Task> GetAll(int? limit, int? offset); - Task Update(Entities.UserAccount userAccount); - Task Delete(Guid id); - Task GetByUsername(string username); - Task GetByEmail(string email); + Task AddAsync(Entities.UserAccount userAccount); + Task GetByIdAsync(Guid id); + Task> GetAllAsync(int? limit, int? offset); + Task UpdateAsync(Entities.UserAccount userAccount); + Task DeleteAsync(Guid id); + Task GetByUsernameAsync(string username); + Task GetByEmailAsync(string email); } } diff --git a/Repository/Repository.Core/Repositories/UserAccount/UserAccountRepository.cs b/Repository/Repository.Core/Repositories/UserAccount/UserAccountRepository.cs index 80ee038..a71d147 100644 --- a/Repository/Repository.Core/Repositories/UserAccount/UserAccountRepository.cs +++ b/Repository/Repository.Core/Repositories/UserAccount/UserAccountRepository.cs @@ -7,7 +7,7 @@ namespace DataAccessLayer.Repositories.UserAccount public class UserAccountRepository(ISqlConnectionFactory connectionFactory) : Repository(connectionFactory), IUserAccountRepository { - public override async Task Add(Entities.UserAccount userAccount) + public override async Task AddAsync(Entities.UserAccount userAccount) { await using var connection = await CreateConnection(); await using var command = new SqlCommand("usp_CreateUserAccount", connection); @@ -23,7 +23,7 @@ namespace DataAccessLayer.Repositories.UserAccount await command.ExecuteNonQueryAsync(); } - public override async Task GetById(Guid id) + public override async Task GetByIdAsync(Guid id) { await using var connection = await CreateConnection(); await using var command = new SqlCommand("usp_GetUserAccountById", connection) @@ -37,7 +37,7 @@ namespace DataAccessLayer.Repositories.UserAccount return await reader.ReadAsync() ? MapToEntity(reader) : null; } - public override async Task> GetAll(int? limit, int? offset) + public override async Task> GetAllAsync(int? limit, int? offset) { await using var connection = await CreateConnection(); await using var command = new SqlCommand("usp_GetAllUserAccounts", connection); @@ -60,7 +60,7 @@ namespace DataAccessLayer.Repositories.UserAccount return users; } - public override async Task Update(Entities.UserAccount userAccount) + public override async Task UpdateAsync(Entities.UserAccount userAccount) { await using var connection = await CreateConnection(); await using var command = new SqlCommand("usp_UpdateUserAccount", connection); @@ -76,7 +76,7 @@ namespace DataAccessLayer.Repositories.UserAccount await command.ExecuteNonQueryAsync(); } - public override async Task Delete(Guid id) + public override async Task DeleteAsync(Guid id) { await using var connection = await CreateConnection(); await using var command = new SqlCommand("usp_DeleteUserAccount", connection); @@ -86,7 +86,7 @@ namespace DataAccessLayer.Repositories.UserAccount await command.ExecuteNonQueryAsync(); } - public async Task GetByUsername(string username) + public async Task GetByUsernameAsync(string username) { await using var connection = await CreateConnection(); await using var command = new SqlCommand("usp_GetUserAccountByUsername", connection); @@ -98,7 +98,7 @@ namespace DataAccessLayer.Repositories.UserAccount return await reader.ReadAsync() ? MapToEntity(reader) : null; } - public async Task GetByEmail(string email) + public async Task GetByEmailAsync(string email) { await using var connection = await CreateConnection(); await using var command = new SqlCommand("usp_GetUserAccountByEmail", connection); diff --git a/Repository/Repository.Core/Repositories/UserCredential/IUserCredentialRepository.cs b/Repository/Repository.Core/Repositories/UserCredential/IUserCredentialRepository.cs index 9ac74c2..b6b4ae8 100644 --- a/Repository/Repository.Core/Repositories/UserCredential/IUserCredentialRepository.cs +++ b/Repository/Repository.Core/Repositories/UserCredential/IUserCredentialRepository.cs @@ -1,11 +1,7 @@ -namespace DataAccessLayer.Repositories.UserCredential; +using DataAccessLayer.Entities; public interface IUserCredentialRepository { - Task Add(Entities.UserCredential credential); - Task GetById(Guid userCredentialId); - Task GetByUserAccountId(Guid userAccountId); - Task> GetAll(int? limit, int? offset); - Task Update(Entities.UserCredential credential); - Task Delete(Guid userCredentialId); -} \ No newline at end of file + Task RotateCredentialAsync(Guid userAccountId, UserCredential credential); + Task GetActiveCredentialByUserAccountIdAsync(Guid userAccountId); +} diff --git a/Repository/Repository.Tests/UserAccountRepositoryTests.cs b/Repository/Repository.Tests/UserAccountRepositoryTests.cs index 3cca273..4c0cb1e 100644 --- a/Repository/Repository.Tests/UserAccountRepositoryTests.cs +++ b/Repository/Repository.Tests/UserAccountRepositoryTests.cs @@ -25,8 +25,8 @@ namespace DALTests }; // Act - await _repository.Add(userAccount); - var retrievedUser = await _repository.GetById(userAccount.UserAccountId); + await _repository.AddAsync(userAccount); + var retrievedUser = await _repository.GetByIdAsync(userAccount.UserAccountId); // Assert Assert.NotNull(retrievedUser); @@ -48,10 +48,10 @@ namespace DALTests CreatedAt = DateTime.UtcNow, DateOfBirth = new DateTime(1985, 5, 15), }; - await _repository.Add(userAccount); + await _repository.AddAsync(userAccount); // Act - var retrievedUser = await _repository.GetById(userId); + var retrievedUser = await _repository.GetByIdAsync(userId); // Assert Assert.NotNull(retrievedUser); @@ -72,12 +72,12 @@ namespace DALTests CreatedAt = DateTime.UtcNow, DateOfBirth = new DateTime(1992, 3, 10), }; - await _repository.Add(userAccount); + await _repository.AddAsync(userAccount); // Act userAccount.FirstName = "Updated"; - await _repository.Update(userAccount); - var updatedUser = await _repository.GetById(userAccount.UserAccountId); + await _repository.UpdateAsync(userAccount); + var updatedUser = await _repository.GetByIdAsync(userAccount.UserAccountId); // Assert Assert.NotNull(updatedUser); @@ -98,11 +98,11 @@ namespace DALTests CreatedAt = DateTime.UtcNow, DateOfBirth = new DateTime(1995, 7, 20), }; - await _repository.Add(userAccount); + await _repository.AddAsync(userAccount); // Act - await _repository.Delete(userAccount.UserAccountId); - var deletedUser = await _repository.GetById(userAccount.UserAccountId); + await _repository.DeleteAsync(userAccount.UserAccountId); + var deletedUser = await _repository.GetByIdAsync(userAccount.UserAccountId); // Assert Assert.Null(deletedUser); @@ -132,11 +132,11 @@ namespace DALTests CreatedAt = DateTime.UtcNow, DateOfBirth = new DateTime(1992, 2, 2), }; - await _repository.Add(user1); - await _repository.Add(user2); + await _repository.AddAsync(user1); + await _repository.AddAsync(user2); // Act - var allUsers = await _repository.GetAll(null, null); + var allUsers = await _repository.GetAllAsync(null, null); // Assert Assert.NotNull(allUsers); @@ -183,11 +183,11 @@ namespace DALTests foreach (var user in users) { - await _repository.Add(user); + await _repository.AddAsync(user); } // Act - var page = (await _repository.GetAll(2, 0)).ToList(); + var page = (await _repository.GetAllAsync(2, 0)).ToList(); // Assert Assert.Equal(2, page.Count); @@ -197,10 +197,10 @@ namespace DALTests public async Task GetAll_WithPagination_ShouldValidateArguments() { await Assert.ThrowsAsync(async () => - (await _repository.GetAll(0, 0)).ToList() + (await _repository.GetAllAsync(0, 0)).ToList() ); await Assert.ThrowsAsync(async () => - (await _repository.GetAll(1, -1)).ToList() + (await _repository.GetAllAsync(1, -1)).ToList() ); } } @@ -209,7 +209,7 @@ namespace DALTests { private readonly Dictionary _store = new(); - public Task Add(UserAccount userAccount) + public Task AddAsync(UserAccount userAccount) { if (userAccount.UserAccountId == Guid.Empty) { @@ -219,13 +219,13 @@ namespace DALTests return Task.CompletedTask; } - public Task GetById(Guid id) + public Task GetByIdAsync(Guid id) { _store.TryGetValue(id, out var user); return Task.FromResult(user is null ? null : Clone(user)); } - public Task> GetAll(int? limit, int? offset) + public Task> GetAllAsync(int? limit, int? offset) { if (limit.HasValue && limit.Value <= 0) throw new ArgumentOutOfRangeException(nameof(limit)); if (offset.HasValue && offset.Value < 0) throw new ArgumentOutOfRangeException(nameof(offset)); @@ -240,26 +240,26 @@ namespace DALTests return Task.FromResult>(query.ToList()); } - public Task Update(UserAccount userAccount) + public Task UpdateAsync(UserAccount userAccount) { if (!_store.ContainsKey(userAccount.UserAccountId)) return Task.CompletedTask; _store[userAccount.UserAccountId] = Clone(userAccount); return Task.CompletedTask; } - public Task Delete(Guid id) + public Task DeleteAsync(Guid id) { _store.Remove(id); return Task.CompletedTask; } - public Task GetByUsername(string username) + public Task GetByUsernameAsync(string username) { var user = _store.Values.FirstOrDefault(u => u.Username == username); return Task.FromResult(user is null ? null : Clone(user)); } - public Task GetByEmail(string email) + public Task GetByEmailAsync(string email) { var user = _store.Values.FirstOrDefault(u => u.Email == email); return Task.FromResult(user is null ? null : Clone(user)); diff --git a/Service/Service.Core/Services/UserService.cs b/Service/Service.Core/Services/UserService.cs index b4c4a85..2085f8b 100644 --- a/Service/Service.Core/Services/UserService.cs +++ b/Service/Service.Core/Services/UserService.cs @@ -8,12 +8,12 @@ namespace BusinessLayer.Services { public async Task> GetAllAsync(int? limit = null, int? offset = null) { - return await repository.GetAll(limit, offset); + return await repository.GetAllAsync(limit, offset); } public async Task GetByIdAsync(Guid id) { - return await repository.GetById(id); + return await repository.GetByIdAsync(id); } } } From 14cb05e992652002a9bbf13666168264a33fe3f6 Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Sat, 24 Jan 2026 19:11:49 -0500 Subject: [PATCH 3/5] Update user credential stored procs --- .../USP_AddUserCredential.sql | 27 +++++++++---------- .../USP_GetUserCredentialByUserAccountId.sql | 2 +- .../USP_InvalidateUserCredential.sql | 25 +++++++++++++++++ .../Repository.Core/Entities/UserAccount.cs | 2 ++ 4 files changed, 40 insertions(+), 16 deletions(-) create mode 100644 Database/Database.Core/scripts/03-crud/02-UserCredential/USP_InvalidateUserCredential.sql diff --git a/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_AddUserCredential.sql b/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_AddUserCredential.sql index f2b7b6a..cd4c183 100644 --- a/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_AddUserCredential.sql +++ b/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_AddUserCredential.sql @@ -1,4 +1,4 @@ -CREATE OR ALTER PROCEDURE dbo.USP_AddUpdateUserCredential( +CREATE OR ALTER PROCEDURE dbo.USP_RotateUserCredential( @UserAccountId UNIQUEIDENTIFIER, @Hash NVARCHAR(MAX) ) @@ -9,25 +9,22 @@ BEGIN BEGIN TRANSACTION; - IF NOT EXISTS ( - SELECT 1 - FROM dbo.UserAccount - WHERE UserAccountID = @UserAccountId - ) - THROW 50001, 'UserAccountID does not exist.', 1; - + IF NOT EXISTS (SELECT 1 + FROM dbo.UserAccount + WHERE UserAccountID = @UserAccountId) + BEGIN + ROLLBACK TRANSACTION; + END - -- invalidate old credentials - UPDATE dbo.UserCredential + -- invalidate all other credentials -- set them to revoked + UPDATE dbo.UserCredential SET IsRevoked = 1, RevokedAt = GETDATE() - WHERE UserAccountId = @UserAccountId - AND IsRevoked = 0; - + WHERE UserAccountId = @UserAccountId; + INSERT INTO dbo.UserCredential (UserAccountId, Hash) - VALUES - (@UserAccountId, @Hash); + VALUES (@UserAccountId, @Hash); COMMIT TRANSACTION; END; \ No newline at end of file diff --git a/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_GetUserCredentialByUserAccountId.sql b/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_GetUserCredentialByUserAccountId.sql index 88a67a5..0ff5aad 100644 --- a/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_GetUserCredentialByUserAccountId.sql +++ b/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_GetUserCredentialByUserAccountId.sql @@ -1,4 +1,4 @@ -CREATE OR ALTER PROCEDURE dbo.USP_GetUserCredentialByUserAccountId( +CREATE OR ALTER PROCEDURE dbo.USP_GetActiveUserCredentialByUserAccountId( @UserAccountId UNIQUEIDENTIFIER ) AS diff --git a/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_InvalidateUserCredential.sql b/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_InvalidateUserCredential.sql new file mode 100644 index 0000000..ba9e601 --- /dev/null +++ b/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_InvalidateUserCredential.sql @@ -0,0 +1,25 @@ +CREATE OR ALTER PROCEDURE dbo.USP_InvalidateUserCredential( + @UserAccountId UNIQUEIDENTIFIER +) +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + + BEGIN TRANSACTION; + + IF NOT EXISTS (SELECT 1 + FROM dbo.UserAccount + WHERE UserAccountID = @UserAccountId) + ROLLBACK TRANSACTION + + + -- invalidate all other credentials by setting them to revoked + UPDATE dbo.UserCredential + SET IsRevoked = 1, + RevokedAt = GETDATE() + WHERE UserAccountId = @UserAccountId AND IsRevoked != 1; + + + COMMIT TRANSACTION; +END; \ No newline at end of file diff --git a/Repository/Repository.Core/Entities/UserAccount.cs b/Repository/Repository.Core/Entities/UserAccount.cs index cf9baf5..ac9b723 100644 --- a/Repository/Repository.Core/Entities/UserAccount.cs +++ b/Repository/Repository.Core/Entities/UserAccount.cs @@ -12,3 +12,5 @@ public class UserAccount public DateTime DateOfBirth { get; set; } public byte[]? Timer { get; set; } } + + From a56ea77861dfc3b5bfa1bbeb2863bedc9ada411e Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Sun, 25 Jan 2026 21:58:26 -0500 Subject: [PATCH 4/5] Add auth service --- API/API.Core/Controllers/AuthController.cs | 47 ++++++++++++++++ API/API.Core/Program.cs | 4 +- Service/Service.Core/Service.Core.csproj | 4 ++ Service/Service.Core/Services/AuthService.cs | 48 ++++++++++++++++ Service/Service.Core/Services/IAuthService.cs | 11 ++++ Service/Service.Core/Services/IUserService.cs | 4 ++ .../Service.Core/Services/PasswordHasher.cs | 56 +++++++++++++++++++ Service/Service.Core/Services/UserService.cs | 10 ++++ 8 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 API/API.Core/Controllers/AuthController.cs create mode 100644 Service/Service.Core/Services/AuthService.cs create mode 100644 Service/Service.Core/Services/IAuthService.cs create mode 100644 Service/Service.Core/Services/PasswordHasher.cs diff --git a/API/API.Core/Controllers/AuthController.cs b/API/API.Core/Controllers/AuthController.cs new file mode 100644 index 0000000..18668d6 --- /dev/null +++ b/API/API.Core/Controllers/AuthController.cs @@ -0,0 +1,47 @@ +using BusinessLayer.Services; +using DataAccessLayer.Entities; +using Microsoft.AspNetCore.Mvc; + +namespace WebAPI.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class AuthController(IAuthService auth) : ControllerBase + { + public record RegisterRequest( + string Username, + string FirstName, + string LastName, + string Email, + DateTime DateOfBirth, + string Password + ); + + public record LoginRequest(string UsernameOrEmail, string Password); + + [HttpPost("register")] + public async Task> Register([FromBody] RegisterRequest req) + { + var user = new UserAccount + { + UserAccountId = Guid.Empty, + Username = req.Username, + FirstName = req.FirstName, + LastName = req.LastName, + Email = req.Email, + DateOfBirth = req.DateOfBirth + }; + + var created = await auth.RegisterAsync(user, req.Password); + return CreatedAtAction(nameof(Register), new { id = created.UserAccountId }, created); + } + + [HttpPost("login")] + public async Task Login([FromBody] LoginRequest req) + { + var ok = await auth.LoginAsync(req.UsernameOrEmail, req.Password); + if (!ok) return Unauthorized(); + return Ok(new { success = true }); + } + } +} diff --git a/API/API.Core/Program.cs b/API/API.Core/Program.cs index 6d36b50..98fb89f 100644 --- a/API/API.Core/Program.cs +++ b/API/API.Core/Program.cs @@ -1,6 +1,6 @@ using BusinessLayer.Services; -using DataAccessLayer.Repositories; using DataAccessLayer.Repositories.UserAccount; +using DataAccessLayer.Repositories.UserCredential; using DataAccessLayer.Sql; var builder = WebApplication.CreateBuilder(args); @@ -14,6 +14,8 @@ builder.Services.AddOpenApi(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); var app = builder.Build(); app.UseSwagger(); diff --git a/Service/Service.Core/Service.Core.csproj b/Service/Service.Core/Service.Core.csproj index 3a607d0..d80bd8b 100644 --- a/Service/Service.Core/Service.Core.csproj +++ b/Service/Service.Core/Service.Core.csproj @@ -6,6 +6,10 @@ BusinessLayer + + + + diff --git a/Service/Service.Core/Services/AuthService.cs b/Service/Service.Core/Services/AuthService.cs new file mode 100644 index 0000000..64aa5b0 --- /dev/null +++ b/Service/Service.Core/Services/AuthService.cs @@ -0,0 +1,48 @@ +using DataAccessLayer.Entities; +using DataAccessLayer.Repositories.UserAccount; +using DataAccessLayer.Repositories.UserCredential; + +namespace BusinessLayer.Services +{ + public class AuthService(IUserAccountRepository userRepo, IUserCredentialRepository credRepo) : IAuthService + { + 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; + } + + public async Task LoginAsync(string usernameOrEmail, string password) + { + // Attempt lookup by username, then email + var user = await userRepo.GetByUsernameAsync(usernameOrEmail) + ?? await userRepo.GetByEmailAsync(usernameOrEmail); + + if (user is null) return false; + + var activeCred = await credRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId); + if (activeCred is null) return false; + + return PasswordHasher.Verify(password, activeCred.Hash); + } + + public async Task InvalidateAsync(Guid userAccountId) + { + await credRepo.InvalidateCredentialsByUserAccountIdAsync(userAccountId); + } + } +} diff --git a/Service/Service.Core/Services/IAuthService.cs b/Service/Service.Core/Services/IAuthService.cs new file mode 100644 index 0000000..490bc6c --- /dev/null +++ b/Service/Service.Core/Services/IAuthService.cs @@ -0,0 +1,11 @@ +using DataAccessLayer.Entities; + +namespace BusinessLayer.Services +{ + public interface IAuthService + { + Task RegisterAsync(UserAccount userAccount, string password); + Task LoginAsync(string usernameOrEmail, string password); + Task InvalidateAsync(Guid userAccountId); + } +} diff --git a/Service/Service.Core/Services/IUserService.cs b/Service/Service.Core/Services/IUserService.cs index d75e665..193a1e3 100644 --- a/Service/Service.Core/Services/IUserService.cs +++ b/Service/Service.Core/Services/IUserService.cs @@ -6,5 +6,9 @@ namespace BusinessLayer.Services { Task> GetAllAsync(int? limit = null, int? offset = null); Task GetByIdAsync(Guid id); + + Task AddAsync(UserAccount userAccount); + + Task UpdateAsync(UserAccount userAccount); } } diff --git a/Service/Service.Core/Services/PasswordHasher.cs b/Service/Service.Core/Services/PasswordHasher.cs new file mode 100644 index 0000000..68c2f94 --- /dev/null +++ b/Service/Service.Core/Services/PasswordHasher.cs @@ -0,0 +1,56 @@ +using System.Security.Cryptography; +using System.Text; +using Konscious.Security.Cryptography; + +namespace BusinessLayer.Services +{ + public static class PasswordHasher + { + private const int SaltSize = 16; // 128-bit + private const int HashSize = 32; // 256-bit + private const int ArgonIterations = 4; + private const int ArgonMemoryKb = 65536; // 64MB + + public static string Hash(string password) + { + var salt = RandomNumberGenerator.GetBytes(SaltSize); + var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password)) + { + Salt = salt, + DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1), + MemorySize = ArgonMemoryKb, + Iterations = ArgonIterations + }; + + var hash = argon2.GetBytes(HashSize); + return $"{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}"; + } + + public static bool Verify(string password, string stored) + { + try + { + var parts = stored.Split(':', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length != 2) return false; + + var salt = Convert.FromBase64String(parts[0]); + var expected = Convert.FromBase64String(parts[1]); + + var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password)) + { + Salt = salt, + DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1), + MemorySize = ArgonMemoryKb, + Iterations = ArgonIterations + }; + + var actual = argon2.GetBytes(expected.Length); + return CryptographicOperations.FixedTimeEquals(actual, expected); + } + catch + { + return false; + } + } + } +} diff --git a/Service/Service.Core/Services/UserService.cs b/Service/Service.Core/Services/UserService.cs index 2085f8b..1445f41 100644 --- a/Service/Service.Core/Services/UserService.cs +++ b/Service/Service.Core/Services/UserService.cs @@ -15,5 +15,15 @@ namespace BusinessLayer.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); + } } } From 68ff549635fe272c3406e04c07cd697e46f94853 Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Sun, 25 Jan 2026 23:26:40 -0500 Subject: [PATCH 5/5] Refactor repository and SQL procedures; add repo tests --- .../scripts/01-schema/schema.sql | 1 - .../01-UserAccount/USP_CreateUserAccount.sql | 11 +- .../01-UserAccount/USP_DeleteUserAccount.sql | 3 - .../01-UserAccount/USP_GetAllUserAccounts.sql | 1 - .../USP_GetUserAccountByEmail.sql | 4 +- .../01-UserAccount/USP_GetUserAccountById.sql | 6 +- .../USP_GetUserAccountByUsername.sql | 4 +- .../01-UserAccount/USP_UpdateUserAccount.sql | 32 +- .../USP_GetUserCredentialByUserAccountId.sql | 1 - .../USP_InvalidateUserCredential.sql | 15 +- .../03-crud/02-Auth/USP_RegisterUser.sql | 42 +++ .../USP_RotateUserCredential.sql} | 20 +- .../USP_AddUserVerification.sql | 14 +- .../03-crud/04-Location/USP_CreateCity.sql | 38 +-- .../03-crud/04-Location/USP_CreateCountry.sql | 18 +- .../04-Location/USP_CreateStateProvince.sql | 22 +- .../Repositories/Repository.cs | 6 +- .../UserAccount/UserAccountRepository.cs | 78 +++-- .../IUserCredentialRepository.cs | 1 + .../UserCredentialRepository.cs | 93 ++++++ .../Sql/DefaultSqlConnectionFactory.cs | 3 +- .../Sql/ISqlConnectionFactory.cs | 4 +- .../DefaultSqlConnectionFactory.test.cs | 73 +++++ .../Database/TestConnectionFactory.cs | 10 + .../Repository.Tests/Repository.Tests.csproj | 11 +- .../UserAccount/UserAccountRepository.test.cs | 136 +++++++++ .../UserAccountRepositoryTests.cs | 281 ------------------ .../UserCredentialRepository.test.cs | 75 +++++ 28 files changed, 573 insertions(+), 430 deletions(-) rename Database/Database.Core/scripts/03-crud/{02-UserCredential => 02-Auth}/USP_GetUserCredentialByUserAccountId.sql (94%) rename Database/Database.Core/scripts/03-crud/{02-UserCredential => 02-Auth}/USP_InvalidateUserCredential.sql (54%) create mode 100644 Database/Database.Core/scripts/03-crud/02-Auth/USP_RegisterUser.sql rename Database/Database.Core/scripts/03-crud/{02-UserCredential/USP_AddUserCredential.sql => 02-Auth/USP_RotateUserCredential.sql} (53%) create mode 100644 Repository/Repository.Core/Repositories/UserCredential/UserCredentialRepository.cs create mode 100644 Repository/Repository.Tests/Database/DefaultSqlConnectionFactory.test.cs create mode 100644 Repository/Repository.Tests/Database/TestConnectionFactory.cs create mode 100644 Repository/Repository.Tests/UserAccount/UserAccountRepository.test.cs delete mode 100644 Repository/Repository.Tests/UserAccountRepositoryTests.cs create mode 100644 Repository/Repository.Tests/UserCredential/UserCredentialRepository.test.cs diff --git a/Database/Database.Core/scripts/01-schema/schema.sql b/Database/Database.Core/scripts/01-schema/schema.sql index 5a09894..1fdf3ba 100644 --- a/Database/Database.Core/scripts/01-schema/schema.sql +++ b/Database/Database.Core/scripts/01-schema/schema.sql @@ -171,7 +171,6 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted Timer ROWVERSION, - CONSTRAINT PK_UserCredential PRIMARY KEY (UserCredentialID), diff --git a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_CreateUserAccount.sql b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_CreateUserAccount.sql index 548bbda..a99b12d 100644 --- a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_CreateUserAccount.sql +++ b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_CreateUserAccount.sql @@ -1,7 +1,7 @@ CREATE OR ALTER PROCEDURE usp_CreateUserAccount ( - @UserAccountId UNIQUEIDENTIFIER = NULL, + @UserAccountId UNIQUEIDENTIFIER OUTPUT, @Username VARCHAR(64), @FirstName NVARCHAR(128), @LastName NVARCHAR(128), @@ -10,13 +10,10 @@ CREATE OR ALTER PROCEDURE usp_CreateUserAccount ) AS BEGIN - SET NOCOUNT ON - SET XACT_ABORT ON - BEGIN TRANSACTION + SET NOCOUNT ON; INSERT INTO UserAccount ( - UserAccountID, Username, FirstName, LastName, @@ -25,12 +22,12 @@ BEGIN ) VALUES ( - COALESCE(@UserAccountId, NEWID()), @Username, @FirstName, @LastName, @DateOfBirth, @Email ); - COMMIT TRANSACTION + + SELECT @UserAccountId AS UserAccountId; END; diff --git a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_DeleteUserAccount.sql b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_DeleteUserAccount.sql index 4813ad5..7ea5d3e 100644 --- a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_DeleteUserAccount.sql +++ b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_DeleteUserAccount.sql @@ -6,8 +6,6 @@ CREATE OR ALTER PROCEDURE usp_DeleteUserAccount AS BEGIN SET NOCOUNT ON - SET XACT_ABORT ON - BEGIN TRANSACTION IF NOT EXISTS (SELECT 1 FROM UserAccount WHERE UserAccountId = @UserAccountId) BEGIN @@ -19,5 +17,4 @@ BEGIN DELETE FROM UserAccount WHERE UserAccountId = @UserAccountId; - COMMIT TRANSACTION END; diff --git a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetAllUserAccounts.sql b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetAllUserAccounts.sql index 799debe..233acf5 100644 --- a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetAllUserAccounts.sql +++ b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetAllUserAccounts.sql @@ -1,4 +1,3 @@ - CREATE OR ALTER PROCEDURE usp_GetAllUserAccounts AS BEGIN diff --git a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountByEmail.sql b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountByEmail.sql index 22d3e44..5e6fc2e 100644 --- a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountByEmail.sql +++ b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountByEmail.sql @@ -1,6 +1,4 @@ - -CREATE OR ALTER PROCEDURE usp_GetUserAccountByEmail -( +CREATE OR ALTER PROCEDURE usp_GetUserAccountByEmail( @Email VARCHAR(128) ) AS diff --git a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountById.sql b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountById.sql index ace7aa5..7113807 100644 --- a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountById.sql +++ b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountById.sql @@ -1,6 +1,4 @@ - -CREATE OR ALTER PROCEDURE usp_GetUserAccountById -( +CREATE OR ALTER PROCEDURE USP_GetUserAccountById( @UserAccountId UNIQUEIDENTIFIER ) AS @@ -18,4 +16,4 @@ BEGIN Timer FROM dbo.UserAccount WHERE UserAccountID = @UserAccountId; -END; +END diff --git a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountByUsername.sql b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountByUsername.sql index 3f12b63..96fcca9 100644 --- a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountByUsername.sql +++ b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountByUsername.sql @@ -1,6 +1,4 @@ - -CREATE OR ALTER PROCEDURE usp_GetUserAccountByUsername -( +CREATE OR ALTER PROCEDURE usp_GetUserAccountByUsername( @Username VARCHAR(64) ) AS diff --git a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_UpdateUserAccount.sql b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_UpdateUserAccount.sql index 1326ef5..e0355b8 100644 --- a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_UpdateUserAccount.sql +++ b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_UpdateUserAccount.sql @@ -1,6 +1,4 @@ - -CREATE OR ALTER PROCEDURE usp_UpdateUserAccount -( +CREATE OR ALTER PROCEDURE usp_UpdateUserAccount( @Username VARCHAR(64), @FirstName NVARCHAR(128), @LastName NVARCHAR(128), @@ -10,26 +8,20 @@ CREATE OR ALTER PROCEDURE usp_UpdateUserAccount ) AS BEGIN - SET NOCOUNT ON - SET XACT_ABORT ON - BEGIN TRANSACTION - - IF NOT EXISTS (SELECT 1 FROM UserAccount WHERE UserAccountId = @UserAccountId) - BEGIN - RAISERROR('UserAccount with the specified ID does not exist.', 16, - 1); - ROLLBACK TRANSACTION - RETURN - END + SET + NOCOUNT ON; UPDATE UserAccount - SET - Username = @Username, - FirstName = @FirstName, - LastName = @LastName, + SET Username = @Username, + FirstName = @FirstName, + LastName = @LastName, DateOfBirth = @DateOfBirth, - Email = @Email + Email = @Email WHERE UserAccountId = @UserAccountId; - COMMIT TRANSACTION + IF @@ROWCOUNT = 0 + BEGIN + THROW + 50001, 'UserAccount with the specified ID does not exist.', 1; + END END; diff --git a/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_GetUserCredentialByUserAccountId.sql b/Database/Database.Core/scripts/03-crud/02-Auth/USP_GetUserCredentialByUserAccountId.sql similarity index 94% rename from Database/Database.Core/scripts/03-crud/02-UserCredential/USP_GetUserCredentialByUserAccountId.sql rename to Database/Database.Core/scripts/03-crud/02-Auth/USP_GetUserCredentialByUserAccountId.sql index 0ff5aad..bc385e9 100644 --- a/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_GetUserCredentialByUserAccountId.sql +++ b/Database/Database.Core/scripts/03-crud/02-Auth/USP_GetUserCredentialByUserAccountId.sql @@ -4,7 +4,6 @@ CREATE OR ALTER PROCEDURE dbo.USP_GetActiveUserCredentialByUserAccountId( AS BEGIN SET NOCOUNT ON; - SET XACT_ABORT ON; SELECT UserCredentialId, diff --git a/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_InvalidateUserCredential.sql b/Database/Database.Core/scripts/03-crud/02-Auth/USP_InvalidateUserCredential.sql similarity index 54% rename from Database/Database.Core/scripts/03-crud/02-UserCredential/USP_InvalidateUserCredential.sql rename to Database/Database.Core/scripts/03-crud/02-Auth/USP_InvalidateUserCredential.sql index ba9e601..08c7d53 100644 --- a/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_InvalidateUserCredential.sql +++ b/Database/Database.Core/scripts/03-crud/02-Auth/USP_InvalidateUserCredential.sql @@ -1,5 +1,5 @@ CREATE OR ALTER PROCEDURE dbo.USP_InvalidateUserCredential( - @UserAccountId UNIQUEIDENTIFIER + @UserAccountId_ UNIQUEIDENTIFIER ) AS BEGIN @@ -8,18 +8,17 @@ BEGIN BEGIN TRANSACTION; - IF NOT EXISTS (SELECT 1 - FROM dbo.UserAccount - WHERE UserAccountID = @UserAccountId) - ROLLBACK TRANSACTION - + EXEC dbo.USP_GetUserAccountByID @UserAccountId = @UserAccountId_; + IF @@ROWCOUNT = 0 + THROW 50001, 'User account not found', 1; -- invalidate all other credentials by setting them to revoked UPDATE dbo.UserCredential SET IsRevoked = 1, RevokedAt = GETDATE() - WHERE UserAccountId = @UserAccountId AND IsRevoked != 1; - + WHERE UserAccountId = @UserAccountId_ + AND IsRevoked != 1; + COMMIT TRANSACTION; END; \ No newline at end of file diff --git a/Database/Database.Core/scripts/03-crud/02-Auth/USP_RegisterUser.sql b/Database/Database.Core/scripts/03-crud/02-Auth/USP_RegisterUser.sql new file mode 100644 index 0000000..8f10b83 --- /dev/null +++ b/Database/Database.Core/scripts/03-crud/02-Auth/USP_RegisterUser.sql @@ -0,0 +1,42 @@ +CREATE OR ALTER PROCEDURE dbo.USP_RegisterUser( + @UserAccountId_ UNIQUEIDENTIFIER OUTPUT, + @Username VARCHAR(64), + @FirstName NVARCHAR(128), + @LastName NVARCHAR(128), + @DateOfBirth DATETIME, + @Email VARCHAR(128), + @Hash NVARCHAR(MAX) +) +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + + BEGIN TRANSACTION; + + EXEC usp_CreateUserAccount + @UserAccountId = @UserAccountId_ OUTPUT, + @Username = @Username, + @FirstName = @FirstName, + @LastName = @LastName, + @DateOfBirth = @DateOfBirth, + @Email = @Email; + + IF @UserAccountId_ IS NULL + BEGIN + THROW 50000, 'Failed to create user account.', 1; + END + + + EXEC dbo.usp_RotateUserCredential + @UserAccountId = @UserAccountId_, + @Hash = @Hash; + + IF @@ROWCOUNT = 0 + BEGIN + THROW 50002, 'Failed to create user credential.', 1; + END + COMMIT TRANSACTION; + + +END \ No newline at end of file diff --git a/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_AddUserCredential.sql b/Database/Database.Core/scripts/03-crud/02-Auth/USP_RotateUserCredential.sql similarity index 53% rename from Database/Database.Core/scripts/03-crud/02-UserCredential/USP_AddUserCredential.sql rename to Database/Database.Core/scripts/03-crud/02-Auth/USP_RotateUserCredential.sql index cd4c183..d4d4e60 100644 --- a/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_AddUserCredential.sql +++ b/Database/Database.Core/scripts/03-crud/02-Auth/USP_RotateUserCredential.sql @@ -1,30 +1,28 @@ CREATE OR ALTER PROCEDURE dbo.USP_RotateUserCredential( - @UserAccountId UNIQUEIDENTIFIER, + @UserAccountId_ UNIQUEIDENTIFIER, @Hash NVARCHAR(MAX) ) AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; - BEGIN TRANSACTION; - IF NOT EXISTS (SELECT 1 - FROM dbo.UserAccount - WHERE UserAccountID = @UserAccountId) - BEGIN - ROLLBACK TRANSACTION; - END + EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountId_ + + IF @@ROWCOUNT = 0 + THROW 50001, 'User account not found', 1; + -- invalidate all other credentials -- set them to revoked UPDATE dbo.UserCredential SET IsRevoked = 1, RevokedAt = GETDATE() - WHERE UserAccountId = @UserAccountId; + WHERE UserAccountId = @UserAccountId_; INSERT INTO dbo.UserCredential (UserAccountId, Hash) - VALUES (@UserAccountId, @Hash); + VALUES (@UserAccountId_, @Hash); + - COMMIT TRANSACTION; END; \ No newline at end of file diff --git a/Database/Database.Core/scripts/03-crud/03-UserVerification/USP_AddUserVerification.sql b/Database/Database.Core/scripts/03-crud/03-UserVerification/USP_AddUserVerification.sql index d556fa2..e2fbe68 100644 --- a/Database/Database.Core/scripts/03-crud/03-UserVerification/USP_AddUserVerification.sql +++ b/Database/Database.Core/scripts/03-crud/03-UserVerification/USP_AddUserVerification.sql @@ -1,6 +1,5 @@ -CREATE OR ALTER PROCEDURE dbo.USP_CreateUserVerification - @UserAccountID uniqueidentifier, - @VerificationDateTime datetime = NULL +CREATE OR ALTER PROCEDURE dbo.USP_CreateUserVerification @UserAccountID_ UNIQUEIDENTIFIER, + @VerificationDateTime DATETIME = NULL AS BEGIN SET NOCOUNT ON; @@ -11,10 +10,13 @@ BEGIN BEGIN TRANSACTION; + EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountID_; + IF @@ROWCOUNT = 0 + THROW 50001, 'Could not find a user with that id', 1; + INSERT INTO dbo.UserVerification - (UserAccountId, VerificationDateTime) - VALUES - (@UserAccountID, @VerificationDateTime); + (UserAccountId, VerificationDateTime) + VALUES (@UserAccountID_, @VerificationDateTime); COMMIT TRANSACTION; END diff --git a/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateCity.sql b/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateCity.sql index bae29a1..0de7c52 100644 --- a/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateCity.sql +++ b/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateCity.sql @@ -1,5 +1,4 @@ -CREATE OR ALTER PROCEDURE dbo.USP_CreateCity -( +CREATE OR ALTER PROCEDURE dbo.USP_CreateCity( @CityName NVARCHAR(100), @StateProvinceCode NVARCHAR(6) ) @@ -8,23 +7,24 @@ BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; - DECLARE @StateProvinceId UNIQUEIDENTIFIER = dbo.UDF_GetStateProvinceIdByCode(@StateProvinceCode); - IF @StateProvinceId IS NULL - BEGIN - RAISERROR('State/province not found for code.', 16, 1); - RETURN; - END + BEGIN TRANSACTION + DECLARE @StateProvinceId UNIQUEIDENTIFIER = dbo.UDF_GetStateProvinceIdByCode(@StateProvinceCode); + IF @StateProvinceId IS NULL + BEGIN + THROW 50001, 'State/province does not exist', 1; + END - IF EXISTS ( - SELECT 1 - FROM dbo.City - WHERE CityName = @CityName - AND StateProvinceID = @StateProvinceId - ) - RETURN; + IF EXISTS (SELECT 1 + FROM dbo.City + WHERE CityName = @CityName + AND StateProvinceID = @StateProvinceId) + BEGIN - INSERT INTO dbo.City - (StateProvinceID, CityName) - VALUES - (@StateProvinceId, @CityName); + THROW 50002, 'City already exists.', 1; + END + + INSERT INTO dbo.City + (StateProvinceID, CityName) + VALUES (@StateProvinceId, @CityName); + COMMIT TRANSACTION END; diff --git a/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateCountry.sql b/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateCountry.sql index e6e79e1..e321746 100644 --- a/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateCountry.sql +++ b/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateCountry.sql @@ -1,5 +1,4 @@ -CREATE OR ALTER PROCEDURE dbo.USP_CreateCountry -( +CREATE OR ALTER PROCEDURE dbo.USP_CreateCountry( @CountryName NVARCHAR(100), @ISO3616_1 NVARCHAR(2) ) @@ -7,16 +6,15 @@ AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; + BEGIN TRANSACTION; - IF EXISTS ( - SELECT 1 - FROM dbo.Country - WHERE ISO3616_1 = @ISO3616_1 - ) - RETURN; + IF EXISTS (SELECT 1 + FROM dbo.Country + WHERE ISO3616_1 = @ISO3616_1) + THROW 50001, 'Country already exists', 1; INSERT INTO dbo.Country (CountryName, ISO3616_1) - VALUES - (@CountryName, @ISO3616_1); + VALUES (@CountryName, @ISO3616_1); + COMMIT TRANSACTION; END; diff --git a/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateStateProvince.sql b/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateStateProvince.sql index 7caca90..39e55c1 100644 --- a/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateStateProvince.sql +++ b/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateStateProvince.sql @@ -1,5 +1,4 @@ -CREATE OR ALTER PROCEDURE dbo.USP_CreateStateProvince -( +CREATE OR ALTER PROCEDURE dbo.USP_CreateStateProvince( @StateProvinceName NVARCHAR(100), @ISO3616_2 NVARCHAR(6), @CountryCode NVARCHAR(2) @@ -9,22 +8,19 @@ BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; - IF EXISTS ( - SELECT 1 - FROM dbo.StateProvince - WHERE ISO3616_2 = @ISO3616_2 - ) + IF EXISTS (SELECT 1 + FROM dbo.StateProvince + WHERE ISO3616_2 = @ISO3616_2) RETURN; DECLARE @CountryId UNIQUEIDENTIFIER = dbo.UDF_GetCountryIdByCode(@CountryCode); IF @CountryId IS NULL - BEGIN - RAISERROR('Country not found for code.', 16, 1); - RETURN; - END + BEGIN + THROW 50001, 'Country does not exist', 1; + + END INSERT INTO dbo.StateProvince (StateProvinceName, ISO3616_2, CountryID) - VALUES - (@StateProvinceName, @ISO3616_2, @CountryId); + VALUES (@StateProvinceName, @ISO3616_2, @CountryId); END; diff --git a/Repository/Repository.Core/Repositories/Repository.cs b/Repository/Repository.Core/Repositories/Repository.cs index dd3de00..154c6d6 100644 --- a/Repository/Repository.Core/Repositories/Repository.cs +++ b/Repository/Repository.Core/Repositories/Repository.cs @@ -1,12 +1,12 @@ +using System.Data.Common; using DataAccessLayer.Sql; -using Microsoft.Data.SqlClient; namespace DataAccessLayer.Repositories { public abstract class Repository(ISqlConnectionFactory connectionFactory) where T : class { - protected async Task CreateConnection() + protected async Task CreateConnection() { var connection = connectionFactory.CreateConnection(); await connection.OpenAsync(); @@ -19,6 +19,6 @@ namespace DataAccessLayer.Repositories public abstract Task UpdateAsync(T entity); public abstract Task DeleteAsync(Guid id); - protected abstract T MapToEntity(SqlDataReader reader); + protected abstract T MapToEntity(DbDataReader reader); } } diff --git a/Repository/Repository.Core/Repositories/UserAccount/UserAccountRepository.cs b/Repository/Repository.Core/Repositories/UserAccount/UserAccountRepository.cs index a71d147..4dab2dc 100644 --- a/Repository/Repository.Core/Repositories/UserAccount/UserAccountRepository.cs +++ b/Repository/Repository.Core/Repositories/UserAccount/UserAccountRepository.cs @@ -1,24 +1,28 @@ using System.Data; +using System.Data.Common; using DataAccessLayer.Sql; -using Microsoft.Data.SqlClient; 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 = new SqlCommand("usp_CreateUserAccount", connection); + await using var command = connection.CreateCommand(); + command.CommandText = "usp_CreateUserAccount"; command.CommandType = CommandType.StoredProcedure; - - command.Parameters.Add("@UserAccountId", SqlDbType.UniqueIdentifier).Value = userAccount.UserAccountId; - command.Parameters.Add("@Username", SqlDbType.NVarChar, 100).Value = userAccount.Username; - command.Parameters.Add("@FirstName", SqlDbType.NVarChar, 100).Value = userAccount.FirstName; - command.Parameters.Add("@LastName", SqlDbType.NVarChar, 100).Value = userAccount.LastName; - command.Parameters.Add("@Email", SqlDbType.NVarChar, 256).Value = userAccount.Email; - command.Parameters.Add("@DateOfBirth", SqlDbType.Date).Value = userAccount.DateOfBirth; + 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(); } @@ -26,12 +30,11 @@ namespace DataAccessLayer.Repositories.UserAccount public override async Task GetByIdAsync(Guid id) { await using var connection = await CreateConnection(); - await using var command = new SqlCommand("usp_GetUserAccountById", connection) - { - CommandType = CommandType.StoredProcedure - }; + await using var command = connection.CreateCommand(); + command.CommandText = "usp_GetUserAccountById"; + command.CommandType = CommandType.StoredProcedure; - command.Parameters.Add("@UserAccountId", SqlDbType.UniqueIdentifier).Value = id; + AddParameter(command, "@UserAccountId", id); await using var reader = await command.ExecuteReaderAsync(); return await reader.ReadAsync() ? MapToEntity(reader) : null; @@ -40,14 +43,15 @@ namespace DataAccessLayer.Repositories.UserAccount public override async Task> GetAllAsync(int? limit, int? offset) { await using var connection = await CreateConnection(); - await using var command = new SqlCommand("usp_GetAllUserAccounts", connection); + await using var command = connection.CreateCommand(); + command.CommandText = "usp_GetAllUserAccounts"; command.CommandType = CommandType.StoredProcedure; if (limit.HasValue) - command.Parameters.Add("@Limit", SqlDbType.Int).Value = limit.Value; + AddParameter(command, "@Limit", limit.Value); if (offset.HasValue) - command.Parameters.Add("@Offset", SqlDbType.Int).Value = offset.Value; + AddParameter(command, "@Offset", offset.Value); await using var reader = await command.ExecuteReaderAsync(); var users = new List(); @@ -63,15 +67,16 @@ namespace DataAccessLayer.Repositories.UserAccount public override async Task UpdateAsync(Entities.UserAccount userAccount) { await using var connection = await CreateConnection(); - await using var command = new SqlCommand("usp_UpdateUserAccount", connection); + await using var command = connection.CreateCommand(); + command.CommandText = "usp_UpdateUserAccount"; command.CommandType = CommandType.StoredProcedure; - command.Parameters.Add("@UserAccountId", SqlDbType.UniqueIdentifier).Value = userAccount.UserAccountId; - command.Parameters.Add("@Username", SqlDbType.NVarChar, 100).Value = userAccount.Username; - command.Parameters.Add("@FirstName", SqlDbType.NVarChar, 100).Value = userAccount.FirstName; - command.Parameters.Add("@LastName", SqlDbType.NVarChar, 100).Value = userAccount.LastName; - command.Parameters.Add("@Email", SqlDbType.NVarChar, 256).Value = userAccount.Email; - command.Parameters.Add("@DateOfBirth", SqlDbType.Date).Value = userAccount.DateOfBirth; + 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(); } @@ -79,20 +84,22 @@ namespace DataAccessLayer.Repositories.UserAccount public override async Task DeleteAsync(Guid id) { await using var connection = await CreateConnection(); - await using var command = new SqlCommand("usp_DeleteUserAccount", connection); + await using var command = connection.CreateCommand(); + command.CommandText = "usp_DeleteUserAccount"; command.CommandType = CommandType.StoredProcedure; - command.Parameters.Add("@UserAccountId", SqlDbType.UniqueIdentifier).Value = id; + AddParameter(command, "@UserAccountId", id); await command.ExecuteNonQueryAsync(); } public async Task GetByUsernameAsync(string username) { await using var connection = await CreateConnection(); - await using var command = new SqlCommand("usp_GetUserAccountByUsername", connection); + await using var command = connection.CreateCommand(); + command.CommandText = "usp_GetUserAccountByUsername"; command.CommandType = CommandType.StoredProcedure; - command.Parameters.Add("@Username", SqlDbType.NVarChar, 100).Value = username; + AddParameter(command, "@Username", username); await using var reader = await command.ExecuteReaderAsync(); return await reader.ReadAsync() ? MapToEntity(reader) : null; @@ -101,16 +108,17 @@ namespace DataAccessLayer.Repositories.UserAccount public async Task GetByEmailAsync(string email) { await using var connection = await CreateConnection(); - await using var command = new SqlCommand("usp_GetUserAccountByEmail", connection); + await using var command = connection.CreateCommand(); + command.CommandText = "usp_GetUserAccountByEmail"; command.CommandType = CommandType.StoredProcedure; - command.Parameters.Add("@Email", SqlDbType.NVarChar, 256).Value = email; + AddParameter(command, "@Email", email); await using var reader = await command.ExecuteReaderAsync(); return await reader.ReadAsync() ? MapToEntity(reader) : null; } - protected override Entities.UserAccount MapToEntity(SqlDataReader reader) + protected override Entities.UserAccount MapToEntity(DbDataReader reader) { return new Entities.UserAccount { @@ -129,5 +137,13 @@ namespace DataAccessLayer.Repositories.UserAccount : (byte[])reader["Timer"] }; } + + private static void AddParameter(DbCommand command, string name, object? value) + { + var p = command.CreateParameter(); + p.ParameterName = name; + p.Value = value ?? DBNull.Value; + command.Parameters.Add(p); + } } } diff --git a/Repository/Repository.Core/Repositories/UserCredential/IUserCredentialRepository.cs b/Repository/Repository.Core/Repositories/UserCredential/IUserCredentialRepository.cs index b6b4ae8..72dadb9 100644 --- a/Repository/Repository.Core/Repositories/UserCredential/IUserCredentialRepository.cs +++ b/Repository/Repository.Core/Repositories/UserCredential/IUserCredentialRepository.cs @@ -4,4 +4,5 @@ public interface IUserCredentialRepository { Task RotateCredentialAsync(Guid userAccountId, UserCredential credential); Task GetActiveCredentialByUserAccountIdAsync(Guid userAccountId); + Task InvalidateCredentialsByUserAccountIdAsync(Guid userAccountId); } diff --git a/Repository/Repository.Core/Repositories/UserCredential/UserCredentialRepository.cs b/Repository/Repository.Core/Repositories/UserCredential/UserCredentialRepository.cs new file mode 100644 index 0000000..e27e03b --- /dev/null +++ b/Repository/Repository.Core/Repositories/UserCredential/UserCredentialRepository.cs @@ -0,0 +1,93 @@ +using System.Data; +using System.Data.Common; +using DataAccessLayer.Sql; + +namespace DataAccessLayer.Repositories.UserCredential +{ + public class UserCredentialRepository(ISqlConnectionFactory connectionFactory) + : DataAccessLayer.Repositories.Repository(connectionFactory), IUserCredentialRepository + { + public async Task RotateCredentialAsync(Guid userAccountId, Entities.UserCredential credential) + { + await using var connection = await CreateConnection(); + await using var command = connection.CreateCommand(); + command.CommandText = "USP_RotateUserCredential"; + command.CommandType = CommandType.StoredProcedure; + + AddParameter(command, "@UserAccountId", userAccountId); + AddParameter(command, "@Hash", credential.Hash); + + await command.ExecuteNonQueryAsync(); + } + + public async Task GetActiveCredentialByUserAccountIdAsync(Guid userAccountId) + { + await using var connection = await CreateConnection(); + await using var command = connection.CreateCommand(); + command.CommandText = "USP_GetActiveUserCredentialByUserAccountId"; + command.CommandType = CommandType.StoredProcedure; + + AddParameter(command, "@UserAccountId", userAccountId); + + await using var reader = await command.ExecuteReaderAsync(); + return await reader.ReadAsync() ? MapToEntity(reader) : null; + } + + public async Task InvalidateCredentialsByUserAccountIdAsync(Guid userAccountId) + { + await using var connection = await CreateConnection(); + await using var command = connection.CreateCommand(); + command.CommandText = "USP_InvalidateUserCredential"; + command.CommandType = CommandType.StoredProcedure; + + AddParameter(command, "@UserAccountId", userAccountId); + 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 + { + UserCredentialId = reader.GetGuid(reader.GetOrdinal("UserCredentialId")), + UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")), + Hash = reader.GetString(reader.GetOrdinal("Hash")), + CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")) + }; + + // Optional columns + var hasTimer = reader.GetSchemaTable()?.Rows + .Cast() + .Any(r => string.Equals(r["ColumnName"]?.ToString(), "Timer", StringComparison.OrdinalIgnoreCase)) ?? false; + + if (hasTimer) + { + entity.Timer = reader.IsDBNull(reader.GetOrdinal("Timer")) ? null : (byte[])reader["Timer"]; + } + + return entity; + } + + private static void AddParameter(DbCommand command, string name, object? value) + { + var p = command.CreateParameter(); + p.ParameterName = name; + p.Value = value ?? DBNull.Value; + command.Parameters.Add(p); + } + } +} diff --git a/Repository/Repository.Core/Sql/DefaultSqlConnectionFactory.cs b/Repository/Repository.Core/Sql/DefaultSqlConnectionFactory.cs index 2011fd5..eb8abad 100644 --- a/Repository/Repository.Core/Sql/DefaultSqlConnectionFactory.cs +++ b/Repository/Repository.Core/Sql/DefaultSqlConnectionFactory.cs @@ -1,3 +1,4 @@ +using System.Data.Common; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Configuration; @@ -12,7 +13,7 @@ namespace DataAccessLayer.Sql "Database connection string not configured. Set DB_CONNECTION_STRING env var or ConnectionStrings:Default." ); - public SqlConnection CreateConnection() + public DbConnection CreateConnection() { return new SqlConnection(_connectionString); } diff --git a/Repository/Repository.Core/Sql/ISqlConnectionFactory.cs b/Repository/Repository.Core/Sql/ISqlConnectionFactory.cs index db981f5..db3de9b 100644 --- a/Repository/Repository.Core/Sql/ISqlConnectionFactory.cs +++ b/Repository/Repository.Core/Sql/ISqlConnectionFactory.cs @@ -1,9 +1,9 @@ -using Microsoft.Data.SqlClient; +using System.Data.Common; namespace DataAccessLayer.Sql { public interface ISqlConnectionFactory { - SqlConnection CreateConnection(); + DbConnection CreateConnection(); } } \ No newline at end of file diff --git a/Repository/Repository.Tests/Database/DefaultSqlConnectionFactory.test.cs b/Repository/Repository.Tests/Database/DefaultSqlConnectionFactory.test.cs new file mode 100644 index 0000000..33e9688 --- /dev/null +++ b/Repository/Repository.Tests/Database/DefaultSqlConnectionFactory.test.cs @@ -0,0 +1,73 @@ +using DataAccessLayer.Sql; +using FluentAssertions; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Configuration; + +namespace Repository.Tests.Database; + +public class DefaultSqlConnectionFactoryTest +{ + private static IConfiguration EmptyConfig() + => new ConfigurationBuilder().AddInMemoryCollection(new Dictionary()).Build(); + + [Fact] + public void CreateConnection_Uses_EnvVar_WhenAvailable() + { + var previous = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING"); + try + { + Environment.SetEnvironmentVariable("DB_CONNECTION_STRING", "Server=localhost;Database=TestDb;Trusted_Connection=True;Encrypt=False"); + var factory = new DefaultSqlConnectionFactory(EmptyConfig()); + + var conn = factory.CreateConnection(); + conn.Should().BeOfType(); + conn.ConnectionString.Should().Contain("Database=TestDb"); + } + finally + { + Environment.SetEnvironmentVariable("DB_CONNECTION_STRING", previous); + } + } + + [Fact] + public void CreateConnection_Uses_Config_WhenEnvMissing() + { + var previous = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING"); + try + { + Environment.SetEnvironmentVariable("DB_CONNECTION_STRING", null); + var cfg = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "ConnectionStrings:Default", "Server=localhost;Database=CfgDb;Trusted_Connection=True;Encrypt=False" } + }) + .Build(); + + var factory = new DefaultSqlConnectionFactory(cfg); + var conn = factory.CreateConnection(); + conn.ConnectionString.Should().Contain("Database=CfgDb"); + } + finally + { + Environment.SetEnvironmentVariable("DB_CONNECTION_STRING", previous); + } + } + + [Fact] + public void Constructor_Throws_When_NoEnv_NoConfig() + { + var previous = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING"); + try + { + Environment.SetEnvironmentVariable("DB_CONNECTION_STRING", null); + var cfg = EmptyConfig(); + Action act = () => _ = new DefaultSqlConnectionFactory(cfg); + act.Should().Throw() + .WithMessage("*Database connection string not configured*"); + } + finally + { + Environment.SetEnvironmentVariable("DB_CONNECTION_STRING", previous); + } + } +} \ No newline at end of file diff --git a/Repository/Repository.Tests/Database/TestConnectionFactory.cs b/Repository/Repository.Tests/Database/TestConnectionFactory.cs new file mode 100644 index 0000000..25c9e94 --- /dev/null +++ b/Repository/Repository.Tests/Database/TestConnectionFactory.cs @@ -0,0 +1,10 @@ +using System.Data.Common; +using DataAccessLayer.Sql; + +namespace Repository.Tests.Database; + +internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory +{ + private readonly DbConnection _conn = conn; + public DbConnection CreateConnection() => _conn; +} \ No newline at end of file diff --git a/Repository/Repository.Tests/Repository.Tests.csproj b/Repository/Repository.Tests/Repository.Tests.csproj index 4fa2c9e..a1d64e0 100644 --- a/Repository/Repository.Tests/Repository.Tests.csproj +++ b/Repository/Repository.Tests/Repository.Tests.csproj @@ -4,14 +4,21 @@ enable enable false - DALTests + Repository.Tests + + + + + + + @@ -21,4 +28,4 @@ - + \ No newline at end of file diff --git a/Repository/Repository.Tests/UserAccount/UserAccountRepository.test.cs b/Repository/Repository.Tests/UserAccount/UserAccountRepository.test.cs new file mode 100644 index 0000000..925017a --- /dev/null +++ b/Repository/Repository.Tests/UserAccount/UserAccountRepository.test.cs @@ -0,0 +1,136 @@ +using Apps72.Dev.Data.DbMocker; +using DataAccessLayer.Repositories.UserAccount; +using FluentAssertions; +using Repository.Tests.Database; + +namespace Repository.Tests.UserAccount; + +public class UserAccountRepositoryTest +{ + private static UserAccountRepository CreateRepo(MockDbConnection conn) + => new(new TestConnectionFactory(conn)); + + [Fact] + public async Task GetByIdAsync_ReturnsRow_Mapped() + { + var conn = new MockDbConnection(); + conn.Mocks + .When(cmd => cmd.CommandText == "usp_GetUserAccountById") + .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.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + "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")); + + result.Should().NotBeNull(); + result!.Username.Should().Be("yerb"); + result.Email.Should().Be("aaronpo@example.com"); + } + + [Fact] + public async Task GetAllAsync_ReturnsMultipleRows() + { + 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)); + + var repo = CreateRepo(conn); + var results = (await repo.GetAllAsync(null, null)).ToList(); + results.Should().HaveCount(2); + 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() + { + var conn = new MockDbConnection(); + conn.Mocks + .When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername") + .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(), "lookupuser","L","U","lookup@example.com", DateTime.UtcNow, null, DateTime.UtcNow.Date, null)); + + var repo = CreateRepo(conn); + var result = await repo.GetByUsernameAsync("lookupuser"); + result.Should().NotBeNull(); + result!.Email.Should().Be("lookup@example.com"); + } + + [Fact] + public async Task GetByEmail_ReturnsRow() + { + var conn = new MockDbConnection(); + conn.Mocks + .When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail") + .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(), "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"); + } +} diff --git a/Repository/Repository.Tests/UserAccountRepositoryTests.cs b/Repository/Repository.Tests/UserAccountRepositoryTests.cs deleted file mode 100644 index 4c0cb1e..0000000 --- a/Repository/Repository.Tests/UserAccountRepositoryTests.cs +++ /dev/null @@ -1,281 +0,0 @@ -using DataAccessLayer; -using DataAccessLayer.Entities; -using DataAccessLayer.Repositories; -using DataAccessLayer.Repositories.UserAccount; - -namespace DALTests -{ - public class UserAccountRepositoryTests - { - private readonly IUserAccountRepository _repository = new InMemoryUserAccountRepository(); - - [Fact] - public async Task Add_ShouldInsertUserAccount() - { - // Arrange - var userAccount = new UserAccount - { - UserAccountId = Guid.NewGuid(), - Username = "testuser", - FirstName = "Test", - LastName = "User", - Email = "testuser@example.com", - CreatedAt = DateTime.UtcNow, - DateOfBirth = new DateTime(1990, 1, 1), - }; - - // Act - await _repository.AddAsync(userAccount); - var retrievedUser = await _repository.GetByIdAsync(userAccount.UserAccountId); - - // Assert - Assert.NotNull(retrievedUser); - Assert.Equal(userAccount.Username, retrievedUser.Username); - } - - [Fact] - public async Task GetById_ShouldReturnUserAccount() - { - // Arrange - var userId = Guid.NewGuid(); - var userAccount = new UserAccount - { - UserAccountId = userId, - Username = "existinguser", - FirstName = "Existing", - LastName = "User", - Email = "existinguser@example.com", - CreatedAt = DateTime.UtcNow, - DateOfBirth = new DateTime(1985, 5, 15), - }; - await _repository.AddAsync(userAccount); - - // Act - var retrievedUser = await _repository.GetByIdAsync(userId); - - // Assert - Assert.NotNull(retrievedUser); - Assert.Equal(userId, retrievedUser.UserAccountId); - } - - [Fact] - public async Task Update_ShouldModifyUserAccount() - { - // Arrange - var userAccount = new UserAccount - { - UserAccountId = Guid.NewGuid(), - Username = "updatableuser", - FirstName = "Updatable", - LastName = "User", - Email = "updatableuser@example.com", - CreatedAt = DateTime.UtcNow, - DateOfBirth = new DateTime(1992, 3, 10), - }; - await _repository.AddAsync(userAccount); - - // Act - userAccount.FirstName = "Updated"; - await _repository.UpdateAsync(userAccount); - var updatedUser = await _repository.GetByIdAsync(userAccount.UserAccountId); - - // Assert - Assert.NotNull(updatedUser); - Assert.Equal("Updated", updatedUser.FirstName); - } - - [Fact] - public async Task Delete_ShouldRemoveUserAccount() - { - // Arrange - var userAccount = new UserAccount - { - UserAccountId = Guid.NewGuid(), - Username = "deletableuser", - FirstName = "Deletable", - LastName = "User", - Email = "deletableuser@example.com", - CreatedAt = DateTime.UtcNow, - DateOfBirth = new DateTime(1995, 7, 20), - }; - await _repository.AddAsync(userAccount); - - // Act - await _repository.DeleteAsync(userAccount.UserAccountId); - var deletedUser = await _repository.GetByIdAsync(userAccount.UserAccountId); - - // Assert - Assert.Null(deletedUser); - } - - [Fact] - public async Task GetAll_ShouldReturnAllUserAccounts() - { - // Arrange - var user1 = new UserAccount - { - UserAccountId = Guid.NewGuid(), - Username = "user1", - FirstName = "User", - LastName = "One", - Email = "user1@example.com", - CreatedAt = DateTime.UtcNow, - DateOfBirth = new DateTime(1990, 1, 1), - }; - var user2 = new UserAccount - { - UserAccountId = Guid.NewGuid(), - Username = "user2", - FirstName = "User", - LastName = "Two", - Email = "user2@example.com", - CreatedAt = DateTime.UtcNow, - DateOfBirth = new DateTime(1992, 2, 2), - }; - await _repository.AddAsync(user1); - await _repository.AddAsync(user2); - - // Act - var allUsers = await _repository.GetAllAsync(null, null); - - // Assert - Assert.NotNull(allUsers); - Assert.True(allUsers.Count() >= 2); - } - - [Fact] - public async Task GetAll_WithPagination_ShouldRespectLimit() - { - // Arrange - var users = new List - { - new UserAccount - { - UserAccountId = Guid.NewGuid(), - Username = $"pageuser_{Guid.NewGuid():N}", - FirstName = "Page", - LastName = "User", - Email = $"pageuser_{Guid.NewGuid():N}@example.com", - CreatedAt = DateTime.UtcNow, - DateOfBirth = new DateTime(1991, 4, 4), - }, - new UserAccount - { - UserAccountId = Guid.NewGuid(), - Username = $"pageuser_{Guid.NewGuid():N}", - FirstName = "Page", - LastName = "User", - Email = $"pageuser_{Guid.NewGuid():N}@example.com", - CreatedAt = DateTime.UtcNow, - DateOfBirth = new DateTime(1992, 5, 5), - }, - new UserAccount - { - UserAccountId = Guid.NewGuid(), - Username = $"pageuser_{Guid.NewGuid():N}", - FirstName = "Page", - LastName = "User", - Email = $"pageuser_{Guid.NewGuid():N}@example.com", - CreatedAt = DateTime.UtcNow, - DateOfBirth = new DateTime(1993, 6, 6), - }, - }; - - foreach (var user in users) - { - await _repository.AddAsync(user); - } - - // Act - var page = (await _repository.GetAllAsync(2, 0)).ToList(); - - // Assert - Assert.Equal(2, page.Count); - } - - [Fact] - public async Task GetAll_WithPagination_ShouldValidateArguments() - { - await Assert.ThrowsAsync(async () => - (await _repository.GetAllAsync(0, 0)).ToList() - ); - await Assert.ThrowsAsync(async () => - (await _repository.GetAllAsync(1, -1)).ToList() - ); - } - } - - internal class InMemoryUserAccountRepository : IUserAccountRepository - { - private readonly Dictionary _store = new(); - - public Task AddAsync(UserAccount userAccount) - { - if (userAccount.UserAccountId == Guid.Empty) - { - userAccount.UserAccountId = Guid.NewGuid(); - } - _store[userAccount.UserAccountId] = Clone(userAccount); - return Task.CompletedTask; - } - - public Task GetByIdAsync(Guid id) - { - _store.TryGetValue(id, out var user); - return Task.FromResult(user is null ? null : Clone(user)); - } - - public Task> GetAllAsync(int? limit, int? offset) - { - if (limit.HasValue && limit.Value <= 0) throw new ArgumentOutOfRangeException(nameof(limit)); - if (offset.HasValue && offset.Value < 0) throw new ArgumentOutOfRangeException(nameof(offset)); - - var query = _store.Values - .OrderBy(u => u.Username) - .Select(Clone); - - if (offset.HasValue) query = query.Skip(offset.Value); - if (limit.HasValue) query = query.Take(limit.Value); - - return Task.FromResult>(query.ToList()); - } - - public Task UpdateAsync(UserAccount userAccount) - { - if (!_store.ContainsKey(userAccount.UserAccountId)) return Task.CompletedTask; - _store[userAccount.UserAccountId] = Clone(userAccount); - return Task.CompletedTask; - } - - public Task DeleteAsync(Guid id) - { - _store.Remove(id); - return Task.CompletedTask; - } - - public Task GetByUsernameAsync(string username) - { - var user = _store.Values.FirstOrDefault(u => u.Username == username); - return Task.FromResult(user is null ? null : Clone(user)); - } - - public Task GetByEmailAsync(string email) - { - var user = _store.Values.FirstOrDefault(u => u.Email == email); - return Task.FromResult(user is null ? null : Clone(user)); - } - - private static UserAccount Clone(UserAccount u) => new() - { - UserAccountId = u.UserAccountId, - Username = u.Username, - FirstName = u.FirstName, - LastName = u.LastName, - Email = u.Email, - CreatedAt = u.CreatedAt, - UpdatedAt = u.UpdatedAt, - DateOfBirth = u.DateOfBirth, - Timer = u.Timer is null ? null : (byte[])u.Timer.Clone(), - }; - } -} diff --git a/Repository/Repository.Tests/UserCredential/UserCredentialRepository.test.cs b/Repository/Repository.Tests/UserCredential/UserCredentialRepository.test.cs new file mode 100644 index 0000000..708b2a5 --- /dev/null +++ b/Repository/Repository.Tests/UserCredential/UserCredentialRepository.test.cs @@ -0,0 +1,75 @@ +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() + { + var conn = new MockDbConnection(); + conn.Mocks + .When(cmd => cmd.CommandText == "USP_RotateUserCredential") + .ReturnsRow(0); + + var repo = new UserCredentialRepository(new TestConnectionFactory(conn)); + var credential = new DataAccessLayer.Entities.UserCredential + { + Hash = "hashed_password" + }; + await repo.RotateCredentialAsync(Guid.NewGuid(), credential); + + } +}