diff --git a/BiatecTokensApi/Services/AuthenticationService.cs b/BiatecTokensApi/Services/AuthenticationService.cs index 68bc645a..cbcf8e42 100644 --- a/BiatecTokensApi/Services/AuthenticationService.cs +++ b/BiatecTokensApi/Services/AuthenticationService.cs @@ -386,7 +386,11 @@ public async Task LogoutAsync(string userId) }, out SecurityToken validatedToken); var jwtToken = (JwtSecurityToken)validatedToken; - var userId = jwtToken.Claims.First(x => x.Type == ClaimTypes.NameIdentifier).Value; + // JwtSecurityToken.Claims returns raw JWT claim names (e.g., "nameid") not the full + // CLR claim type URIs (e.g., ClaimTypes.NameIdentifier). Check both for compatibility. + var userId = jwtToken.Claims + .FirstOrDefault(x => x.Type == ClaimTypes.NameIdentifier || x.Type == "nameid") + ?.Value; return Task.FromResult(userId); } diff --git a/BiatecTokensTests/AuthenticationServiceUnitTests.cs b/BiatecTokensTests/AuthenticationServiceUnitTests.cs new file mode 100644 index 00000000..97e212a1 --- /dev/null +++ b/BiatecTokensTests/AuthenticationServiceUnitTests.cs @@ -0,0 +1,1055 @@ +using BiatecTokensApi.Configuration; +using BiatecTokensApi.Models; +using BiatecTokensApi.Models.Auth; +using BiatecTokensApi.Repositories.Interface; +using BiatecTokensApi.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using Moq; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +namespace BiatecTokensTests +{ + /// + /// Unit tests for . + /// + /// These tests validate: + /// - User registration (success, weak password, duplicate email) + /// - Login (success, invalid credentials, non-existent user, locked account, inactive account) + /// - Token refresh (success, invalid/revoked/expired token) + /// - Logout (success) + /// - JWT access token validation + /// - Password change + /// - ARC76 derivation verification + /// - Derivation info (static contract) + /// - Session inspection + /// + [TestFixture] + public class AuthenticationServiceUnitTests + { + private Mock _userRepoMock = null!; + private Mock> _loggerMock = null!; + private IOptions _jwtOptions = null!; + private KeyProviderFactory _keyProviderFactory = null!; + private AuthenticationService _service = null!; + + private const string TestPassword = "SecurePass123!"; + private const string JwtSecretKey = "AuthUnitTestSecretKey32CharsRequired!!"; + private const string JwtIssuer = "BiatecTokensApi"; + private const string JwtAudience = "BiatecTokensUsers"; + + [SetUp] + public void SetUp() + { + _userRepoMock = new Mock(); + _loggerMock = new Mock>(); + + _jwtOptions = Options.Create(new JwtConfig + { + SecretKey = JwtSecretKey, + Issuer = JwtIssuer, + Audience = JwtAudience, + AccessTokenExpirationMinutes = 60, + RefreshTokenExpirationDays = 30, + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true + }); + + // Build a minimal service container for KeyProviderFactory + var services = new ServiceCollection(); + services.Configure(c => + { + c.Provider = "Hardcoded"; + c.HardcodedKey = "AuthUnitTestEncryptionKey32CharsReqd!"; + }); + services.AddLogging(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + var sp = services.BuildServiceProvider(); + _keyProviderFactory = sp.GetRequiredService(); + + _service = new AuthenticationService( + _userRepoMock.Object, + _loggerMock.Object, + _jwtOptions, + _keyProviderFactory); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static string UniqueEmail() => $"unit-{Guid.NewGuid():N}@biatec-test.example.com"; + + private static User BuildUser(string? email = null, bool isActive = true, bool isLocked = false) + { + return new User + { + UserId = Guid.NewGuid().ToString(), + Email = email ?? "test@biatec-test.example.com", + // Placeholder hash - does NOT match any real password. + // Only use this helper in tests where password verification is NOT exercised + // (e.g., locked-account tests, inactive-account tests, user-lookup tests). + // For tests that require successful password verification, register via + // _service.RegisterAsync() first and capture the real User from the mock callback. + PasswordHash = "salt:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + AlgorandAddress = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + EncryptedMnemonic = "encrypted_mnemonic_placeholder", + IsActive = isActive, + LockedUntil = isLocked ? DateTime.UtcNow.AddMinutes(30) : null, + FailedLoginAttempts = 0 + }; + } + + private static RegisterRequest BuildRegisterRequest(string? email = null, string? password = null) + { + var pwd = password ?? TestPassword; + return new RegisterRequest + { + Email = email ?? UniqueEmail(), + Password = pwd, + ConfirmPassword = pwd, + FullName = "Test User" + }; + } + + // ── Registration: success path ──────────────────────────────────────────── + + [Test] + public async Task RegisterAsync_ValidRequest_ReturnsSuccess() + { + var req = BuildRegisterRequest(); + _userRepoMock.Setup(r => r.UserExistsAsync(It.IsAny())).ReturnsAsync(false); + _userRepoMock.Setup(r => r.CreateUserAsync(It.IsAny())).ReturnsAsync((User u) => u); + _userRepoMock.Setup(r => r.StoreRefreshTokenAsync(It.IsAny())).Returns(Task.CompletedTask); + + var result = await _service.RegisterAsync(req, null, null); + + Assert.That(result.Success, Is.True, "Valid registration must succeed"); + } + + [Test] + public async Task RegisterAsync_ValidRequest_ReturnsAlgorandAddress() + { + var req = BuildRegisterRequest(); + _userRepoMock.Setup(r => r.UserExistsAsync(It.IsAny())).ReturnsAsync(false); + _userRepoMock.Setup(r => r.CreateUserAsync(It.IsAny())).ReturnsAsync((User u) => u); + _userRepoMock.Setup(r => r.StoreRefreshTokenAsync(It.IsAny())).Returns(Task.CompletedTask); + + var result = await _service.RegisterAsync(req, null, null); + + Assert.That(result.AlgorandAddress, Is.Not.Null.And.Not.Empty, + "Algorand address must be returned on registration"); + Assert.That(result.AlgorandAddress!.Length, Is.EqualTo(58), + "Algorand address must be 58 characters"); + } + + [Test] + public async Task RegisterAsync_ValidRequest_ReturnsAccessToken() + { + var req = BuildRegisterRequest(); + _userRepoMock.Setup(r => r.UserExistsAsync(It.IsAny())).ReturnsAsync(false); + _userRepoMock.Setup(r => r.CreateUserAsync(It.IsAny())).ReturnsAsync((User u) => u); + _userRepoMock.Setup(r => r.StoreRefreshTokenAsync(It.IsAny())).Returns(Task.CompletedTask); + + var result = await _service.RegisterAsync(req, null, null); + + Assert.That(result.AccessToken, Is.Not.Null.And.Not.Empty, + "JWT access token must be returned on registration"); + } + + [Test] + public async Task RegisterAsync_ValidRequest_ReturnsRefreshToken() + { + var req = BuildRegisterRequest(); + _userRepoMock.Setup(r => r.UserExistsAsync(It.IsAny())).ReturnsAsync(false); + _userRepoMock.Setup(r => r.CreateUserAsync(It.IsAny())).ReturnsAsync((User u) => u); + _userRepoMock.Setup(r => r.StoreRefreshTokenAsync(It.IsAny())).Returns(Task.CompletedTask); + + var result = await _service.RegisterAsync(req, null, null); + + Assert.That(result.RefreshToken, Is.Not.Null.And.Not.Empty, + "Refresh token must be returned on registration"); + } + + [Test] + public async Task RegisterAsync_ValidRequest_ReturnsUserId() + { + var req = BuildRegisterRequest(); + _userRepoMock.Setup(r => r.UserExistsAsync(It.IsAny())).ReturnsAsync(false); + _userRepoMock.Setup(r => r.CreateUserAsync(It.IsAny())).ReturnsAsync((User u) => u); + _userRepoMock.Setup(r => r.StoreRefreshTokenAsync(It.IsAny())).Returns(Task.CompletedTask); + + var result = await _service.RegisterAsync(req, null, null); + + Assert.That(result.UserId, Is.Not.Null.And.Not.Empty, "UserId must be returned"); + } + + [Test] + public async Task RegisterAsync_ValidRequest_ReturnsDerivationContractVersion() + { + var req = BuildRegisterRequest(); + _userRepoMock.Setup(r => r.UserExistsAsync(It.IsAny())).ReturnsAsync(false); + _userRepoMock.Setup(r => r.CreateUserAsync(It.IsAny())).ReturnsAsync((User u) => u); + _userRepoMock.Setup(r => r.StoreRefreshTokenAsync(It.IsAny())).Returns(Task.CompletedTask); + + var result = await _service.RegisterAsync(req, null, null); + + Assert.That(result.DerivationContractVersion, Is.Not.Null.And.Not.Empty, + "DerivationContractVersion must be returned"); + } + + [Test] + public async Task RegisterAsync_SameCredentials_AlwaysSameAlgorandAddress() + { + var email = "determinism@biatec-test.example.com"; + + _userRepoMock.Setup(r => r.UserExistsAsync(It.IsAny())).ReturnsAsync(false); + _userRepoMock.Setup(r => r.CreateUserAsync(It.IsAny())).ReturnsAsync((User u) => u); + _userRepoMock.Setup(r => r.StoreRefreshTokenAsync(It.IsAny())).Returns(Task.CompletedTask); + + var req1 = BuildRegisterRequest(email, TestPassword); + var req2 = BuildRegisterRequest(email, TestPassword); + + var result1 = await _service.RegisterAsync(req1, null, null); + var result2 = await _service.RegisterAsync(req2, null, null); + + Assert.That(result1.AlgorandAddress, Is.EqualTo(result2.AlgorandAddress), + "ARC76 derivation must be deterministic: same credentials must always produce same address"); + } + + // ── Registration: failure paths ─────────────────────────────────────────── + + [Test] + public async Task RegisterAsync_WeakPassword_ReturnsFailed() + { + var req = BuildRegisterRequest(password: "weak"); + + var result = await _service.RegisterAsync(req, null, null); + + Assert.That(result.Success, Is.False, "Weak password must be rejected"); + Assert.That(result.ErrorCode, Is.EqualTo(ErrorCodes.WEAK_PASSWORD)); + } + + [Test] + public async Task RegisterAsync_PasswordTooShort_ReturnsFailed() + { + var req = BuildRegisterRequest(password: "short"); + + var result = await _service.RegisterAsync(req, null, null); + + Assert.That(result.Success, Is.False, "Password shorter than 8 characters must be rejected"); + } + + [Test] + public async Task RegisterAsync_PasswordNoUpperCase_ReturnsFailed() + { + var req = BuildRegisterRequest(password: "nouppercase123!"); + + var result = await _service.RegisterAsync(req, null, null); + + Assert.That(result.Success, Is.False, "Password without uppercase must be rejected"); + } + + [Test] + public async Task RegisterAsync_PasswordNoDigit_ReturnsFailed() + { + var req = BuildRegisterRequest(password: "NoDigitPass!"); + + var result = await _service.RegisterAsync(req, null, null); + + Assert.That(result.Success, Is.False, "Password without digit must be rejected"); + } + + [Test] + public async Task RegisterAsync_PasswordNoSpecial_ReturnsFailed() + { + var req = BuildRegisterRequest(password: "NoSpecial123"); + + var result = await _service.RegisterAsync(req, null, null); + + Assert.That(result.Success, Is.False, "Password without special character must be rejected"); + } + + [Test] + public async Task RegisterAsync_DuplicateEmail_ReturnsFailed() + { + var email = UniqueEmail(); + var req = BuildRegisterRequest(email: email); + + _userRepoMock.Setup(r => r.UserExistsAsync(It.Is(e => + e == email.ToLowerInvariant()))).ReturnsAsync(true); + + var result = await _service.RegisterAsync(req, null, null); + + Assert.That(result.Success, Is.False, "Duplicate email must be rejected"); + Assert.That(result.ErrorCode, Is.EqualTo(ErrorCodes.USER_ALREADY_EXISTS)); + } + + [Test] + public async Task RegisterAsync_RepositoryThrows_ReturnsFailed() + { + var req = BuildRegisterRequest(); + _userRepoMock.Setup(r => r.UserExistsAsync(It.IsAny())).ReturnsAsync(false); + _userRepoMock.Setup(r => r.CreateUserAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Repository failure")); + + var result = await _service.RegisterAsync(req, null, null); + + Assert.That(result.Success, Is.False, "Repository failure must return failed response"); + Assert.That(result.ErrorCode, Is.EqualTo(ErrorCodes.INTERNAL_SERVER_ERROR)); + } + + // ── Login: success path ─────────────────────────────────────────────────── + + [Test] + public async Task LoginAsync_ValidCredentials_ReturnsSuccess() + { + var email = UniqueEmail(); + + // Register first to get a real password hash and encrypted mnemonic + _userRepoMock.Setup(r => r.UserExistsAsync(It.IsAny())).ReturnsAsync(false); + _userRepoMock.Setup(r => r.CreateUserAsync(It.IsAny())).ReturnsAsync((User u) => u); + _userRepoMock.Setup(r => r.StoreRefreshTokenAsync(It.IsAny())).Returns(Task.CompletedTask); + + User? registeredUser = null; + _userRepoMock.Setup(r => r.CreateUserAsync(It.IsAny())) + .Callback(u => registeredUser = u) + .ReturnsAsync((User u) => u); + + var regReq = BuildRegisterRequest(email: email); + await _service.RegisterAsync(regReq, null, null); + + // Now login using the registered user's data + _userRepoMock.Setup(r => r.GetUserByEmailAsync(It.Is(e => + e.ToLowerInvariant() == email.ToLowerInvariant()))) + .ReturnsAsync(registeredUser); + _userRepoMock.Setup(r => r.UpdateUserAsync(It.IsAny())).Returns(Task.CompletedTask); + + var loginReq = new LoginRequest { Email = email, Password = TestPassword }; + var result = await _service.LoginAsync(loginReq, null, null); + + Assert.That(result.Success, Is.True, "Login with correct credentials must succeed"); + } + + [Test] + public async Task LoginAsync_ValidCredentials_ReturnsAlgorandAddress() + { + var email = UniqueEmail(); + User? registeredUser = null; + + _userRepoMock.Setup(r => r.UserExistsAsync(It.IsAny())).ReturnsAsync(false); + _userRepoMock.Setup(r => r.CreateUserAsync(It.IsAny())) + .Callback(u => registeredUser = u) + .ReturnsAsync((User u) => u); + _userRepoMock.Setup(r => r.StoreRefreshTokenAsync(It.IsAny())).Returns(Task.CompletedTask); + + var regReq = BuildRegisterRequest(email: email); + await _service.RegisterAsync(regReq, null, null); + + _userRepoMock.Setup(r => r.GetUserByEmailAsync(It.IsAny())) + .ReturnsAsync(registeredUser); + _userRepoMock.Setup(r => r.UpdateUserAsync(It.IsAny())).Returns(Task.CompletedTask); + + var loginReq = new LoginRequest { Email = email, Password = TestPassword }; + var result = await _service.LoginAsync(loginReq, null, null); + + Assert.That(result.AlgorandAddress, Is.Not.Null.And.Not.Empty, + "Algorand address must be returned on successful login"); + } + + [Test] + public async Task LoginAsync_ValidCredentials_AddressMatchesRegistration() + { + var email = UniqueEmail(); + User? registeredUser = null; + + _userRepoMock.Setup(r => r.UserExistsAsync(It.IsAny())).ReturnsAsync(false); + _userRepoMock.Setup(r => r.CreateUserAsync(It.IsAny())) + .Callback(u => registeredUser = u) + .ReturnsAsync((User u) => u); + _userRepoMock.Setup(r => r.StoreRefreshTokenAsync(It.IsAny())).Returns(Task.CompletedTask); + + var regReq = BuildRegisterRequest(email: email); + var regResult = await _service.RegisterAsync(regReq, null, null); + + _userRepoMock.Setup(r => r.GetUserByEmailAsync(It.IsAny())) + .ReturnsAsync(registeredUser); + _userRepoMock.Setup(r => r.UpdateUserAsync(It.IsAny())).Returns(Task.CompletedTask); + + var loginReq = new LoginRequest { Email = email, Password = TestPassword }; + var loginResult = await _service.LoginAsync(loginReq, null, null); + + Assert.That(loginResult.AlgorandAddress, Is.EqualTo(regResult.AlgorandAddress), + "Address returned on login must match address returned on registration (ARC76 consistency)"); + } + + // ── Login: failure paths ────────────────────────────────────────────────── + + [Test] + public async Task LoginAsync_NonExistentUser_ReturnsFailed() + { + _userRepoMock.Setup(r => r.GetUserByEmailAsync(It.IsAny())) + .ReturnsAsync((User?)null); + + var result = await _service.LoginAsync( + new LoginRequest { Email = "ghost@nobody.example.com", Password = TestPassword }, + null, null); + + Assert.That(result.Success, Is.False, "Non-existent user must be rejected"); + Assert.That(result.ErrorCode, Is.EqualTo(ErrorCodes.INVALID_CREDENTIALS)); + } + + [Test] + public async Task LoginAsync_LockedAccount_ReturnsAccountLocked() + { + var user = BuildUser(isLocked: true); + _userRepoMock.Setup(r => r.GetUserByEmailAsync(It.IsAny())) + .ReturnsAsync(user); + + var result = await _service.LoginAsync( + new LoginRequest { Email = user.Email, Password = TestPassword }, + null, null); + + Assert.That(result.Success, Is.False, "Locked account must be rejected"); + Assert.That(result.ErrorCode, Is.EqualTo(ErrorCodes.ACCOUNT_LOCKED)); + } + + [Test] + public async Task LoginAsync_InactiveAccount_ReturnsAccountInactive() + { + var user = BuildUser(isActive: false); + _userRepoMock.Setup(r => r.GetUserByEmailAsync(It.IsAny())) + .ReturnsAsync(user); + + var result = await _service.LoginAsync( + new LoginRequest { Email = user.Email, Password = TestPassword }, + null, null); + + Assert.That(result.Success, Is.False, "Inactive account must be rejected"); + Assert.That(result.ErrorCode, Is.EqualTo(ErrorCodes.ACCOUNT_INACTIVE)); + } + + [Test] + public async Task LoginAsync_InvalidPassword_ReturnsInvalidCredentials() + { + var email = UniqueEmail(); + User? registeredUser = null; + + _userRepoMock.Setup(r => r.UserExistsAsync(It.IsAny())).ReturnsAsync(false); + _userRepoMock.Setup(r => r.CreateUserAsync(It.IsAny())) + .Callback(u => registeredUser = u) + .ReturnsAsync((User u) => u); + _userRepoMock.Setup(r => r.StoreRefreshTokenAsync(It.IsAny())).Returns(Task.CompletedTask); + + await _service.RegisterAsync(BuildRegisterRequest(email), null, null); + + _userRepoMock.Setup(r => r.GetUserByEmailAsync(It.IsAny())) + .ReturnsAsync(registeredUser); + _userRepoMock.Setup(r => r.UpdateUserAsync(It.IsAny())).Returns(Task.CompletedTask); + + var result = await _service.LoginAsync( + new LoginRequest { Email = email, Password = "WrongPassword999!" }, + null, null); + + Assert.That(result.Success, Is.False, "Invalid password must be rejected"); + Assert.That(result.ErrorCode, Is.EqualTo(ErrorCodes.INVALID_CREDENTIALS)); + } + + [Test] + public async Task LoginAsync_FailedAttempts_IncrementFailedLoginAttempts() + { + var email = UniqueEmail(); + User? registeredUser = null; + + _userRepoMock.Setup(r => r.UserExistsAsync(It.IsAny())).ReturnsAsync(false); + _userRepoMock.Setup(r => r.CreateUserAsync(It.IsAny())) + .Callback(u => registeredUser = u) + .ReturnsAsync((User u) => u); + _userRepoMock.Setup(r => r.StoreRefreshTokenAsync(It.IsAny())).Returns(Task.CompletedTask); + + await _service.RegisterAsync(BuildRegisterRequest(email), null, null); + + _userRepoMock.Setup(r => r.GetUserByEmailAsync(It.IsAny())) + .ReturnsAsync(registeredUser); + _userRepoMock.Setup(r => r.UpdateUserAsync(It.IsAny())) + .Callback(u => registeredUser = u) + .Returns(Task.CompletedTask); + + // Make 1 failed attempt + await _service.LoginAsync( + new LoginRequest { Email = email, Password = "Wrong1!" }, null, null); + + Assert.That(registeredUser!.FailedLoginAttempts, Is.GreaterThan(0), + "Failed login attempts must be incremented"); + } + + [Test] + public async Task LoginAsync_FiveFailedAttempts_AccountGetsLocked() + { + var email = UniqueEmail(); + User? registeredUser = null; + + _userRepoMock.Setup(r => r.UserExistsAsync(It.IsAny())).ReturnsAsync(false); + _userRepoMock.Setup(r => r.CreateUserAsync(It.IsAny())) + .Callback(u => registeredUser = u) + .ReturnsAsync((User u) => u); + _userRepoMock.Setup(r => r.StoreRefreshTokenAsync(It.IsAny())).Returns(Task.CompletedTask); + + await _service.RegisterAsync(BuildRegisterRequest(email), null, null); + + _userRepoMock.Setup(r => r.GetUserByEmailAsync(It.IsAny())) + .ReturnsAsync(registeredUser); + _userRepoMock.Setup(r => r.UpdateUserAsync(It.IsAny())) + .Callback(u => registeredUser = u) + .Returns(Task.CompletedTask); + + // Simulate 5 failed attempts + for (var i = 0; i < 5; i++) + { + await _service.LoginAsync( + new LoginRequest { Email = email, Password = "WrongPass!" }, null, null); + } + + Assert.That(registeredUser!.LockedUntil, Is.Not.Null, + "Account must be locked after 5 failed login attempts"); + Assert.That(registeredUser.LockedUntil, Is.GreaterThan(DateTime.UtcNow), + "Lock expiry must be in the future"); + } + + // ── Token refresh ───────────────────────────────────────────────────────── + + [Test] + public async Task RefreshTokenAsync_InvalidToken_ReturnsFailed() + { + _userRepoMock.Setup(r => r.GetRefreshTokenAsync(It.IsAny())) + .ReturnsAsync((RefreshToken?)null); + + var result = await _service.RefreshTokenAsync("invalid-token", null, null); + + Assert.That(result.Success, Is.False, "Invalid refresh token must be rejected"); + Assert.That(result.ErrorCode, Is.EqualTo(ErrorCodes.INVALID_REFRESH_TOKEN)); + } + + [Test] + public async Task RefreshTokenAsync_RevokedToken_ReturnsFailed() + { + var token = new RefreshToken + { + TokenId = Guid.NewGuid().ToString(), + Token = "revoked-token", + UserId = Guid.NewGuid().ToString(), + IsRevoked = true, + ExpiresAt = DateTime.UtcNow.AddDays(30), + CreatedAt = DateTime.UtcNow + }; + _userRepoMock.Setup(r => r.GetRefreshTokenAsync(token.Token)) + .ReturnsAsync(token); + + var result = await _service.RefreshTokenAsync(token.Token, null, null); + + Assert.That(result.Success, Is.False, "Revoked refresh token must be rejected"); + Assert.That(result.ErrorCode, Is.EqualTo(ErrorCodes.REFRESH_TOKEN_REVOKED)); + } + + [Test] + public async Task RefreshTokenAsync_ExpiredToken_ReturnsFailed() + { + var token = new RefreshToken + { + TokenId = Guid.NewGuid().ToString(), + Token = "expired-token", + UserId = Guid.NewGuid().ToString(), + IsRevoked = false, + ExpiresAt = DateTime.UtcNow.AddDays(-1), // Already expired + CreatedAt = DateTime.UtcNow.AddDays(-31) + }; + _userRepoMock.Setup(r => r.GetRefreshTokenAsync(token.Token)) + .ReturnsAsync(token); + + var result = await _service.RefreshTokenAsync(token.Token, null, null); + + Assert.That(result.Success, Is.False, "Expired refresh token must be rejected"); + Assert.That(result.ErrorCode, Is.EqualTo(ErrorCodes.REFRESH_TOKEN_EXPIRED)); + } + + [Test] + public async Task RefreshTokenAsync_UserNotFound_ReturnsFailed() + { + var token = new RefreshToken + { + TokenId = Guid.NewGuid().ToString(), + Token = "valid-token", + UserId = Guid.NewGuid().ToString(), + IsRevoked = false, + ExpiresAt = DateTime.UtcNow.AddDays(30), + CreatedAt = DateTime.UtcNow + }; + _userRepoMock.Setup(r => r.GetRefreshTokenAsync(token.Token)) + .ReturnsAsync(token); + _userRepoMock.Setup(r => r.GetUserByIdAsync(token.UserId)) + .ReturnsAsync((User?)null); + + var result = await _service.RefreshTokenAsync(token.Token, null, null); + + Assert.That(result.Success, Is.False, "Missing user during refresh must be rejected"); + Assert.That(result.ErrorCode, Is.EqualTo(ErrorCodes.USER_NOT_FOUND)); + } + + [Test] + public async Task RefreshTokenAsync_InactiveUser_ReturnsFailed() + { + var user = BuildUser(isActive: false); + var token = new RefreshToken + { + TokenId = Guid.NewGuid().ToString(), + Token = "valid-token", + UserId = user.UserId, + IsRevoked = false, + ExpiresAt = DateTime.UtcNow.AddDays(30), + CreatedAt = DateTime.UtcNow + }; + _userRepoMock.Setup(r => r.GetRefreshTokenAsync(token.Token)) + .ReturnsAsync(token); + _userRepoMock.Setup(r => r.GetUserByIdAsync(user.UserId)) + .ReturnsAsync(user); + + var result = await _service.RefreshTokenAsync(token.Token, null, null); + + Assert.That(result.Success, Is.False, "Inactive user during refresh must be rejected"); + } + + [Test] + public async Task RefreshTokenAsync_ValidToken_ReturnsNewAccessToken() + { + var user = BuildUser(isActive: true); + var token = new RefreshToken + { + TokenId = Guid.NewGuid().ToString(), + Token = "valid-token", + UserId = user.UserId, + IsRevoked = false, + ExpiresAt = DateTime.UtcNow.AddDays(30), + CreatedAt = DateTime.UtcNow + }; + _userRepoMock.Setup(r => r.GetRefreshTokenAsync(token.Token)).ReturnsAsync(token); + _userRepoMock.Setup(r => r.GetUserByIdAsync(user.UserId)).ReturnsAsync(user); + _userRepoMock.Setup(r => r.RevokeRefreshTokenAsync(token.Token)).Returns(Task.CompletedTask); + _userRepoMock.Setup(r => r.StoreRefreshTokenAsync(It.IsAny())).Returns(Task.CompletedTask); + + var result = await _service.RefreshTokenAsync(token.Token, null, null); + + Assert.That(result.Success, Is.True, "Valid refresh must succeed"); + Assert.That(result.AccessToken, Is.Not.Null.And.Not.Empty, "New access token must be returned"); + Assert.That(result.RefreshToken, Is.Not.Null.And.Not.Empty, "New refresh token must be returned"); + } + + // ── Logout ──────────────────────────────────────────────────────────────── + + [Test] + public async Task LogoutAsync_ValidUserId_ReturnsSuccess() + { + var userId = Guid.NewGuid().ToString(); + _userRepoMock.Setup(r => r.RevokeAllUserRefreshTokensAsync(userId)) + .Returns(Task.CompletedTask); + + var result = await _service.LogoutAsync(userId); + + Assert.That(result.Success, Is.True, "Valid logout must succeed"); + } + + [Test] + public async Task LogoutAsync_ValidUserId_CallsRevokeAllTokens() + { + var userId = Guid.NewGuid().ToString(); + _userRepoMock.Setup(r => r.RevokeAllUserRefreshTokensAsync(userId)) + .Returns(Task.CompletedTask); + + await _service.LogoutAsync(userId); + + _userRepoMock.Verify(r => r.RevokeAllUserRefreshTokensAsync(userId), Times.Once, + "RevokeAllUserRefreshTokensAsync must be called on logout"); + } + + [Test] + public async Task LogoutAsync_RepositoryThrows_ReturnsFailed() + { + var userId = Guid.NewGuid().ToString(); + _userRepoMock.Setup(r => r.RevokeAllUserRefreshTokensAsync(userId)) + .ThrowsAsync(new InvalidOperationException("Repository failure")); + + var result = await _service.LogoutAsync(userId); + + Assert.That(result.Success, Is.False, "Repository failure during logout must return failed response"); + } + + // ── ValidateAccessTokenAsync ────────────────────────────────────────────── + + [Test] + public async Task ValidateAccessTokenAsync_InvalidToken_ReturnsNull() + { + var result = await _service.ValidateAccessTokenAsync("invalid.token.value"); + + Assert.That(result, Is.Null, "Invalid token must return null"); + } + + [Test] + public async Task ValidateAccessTokenAsync_EmptyToken_ReturnsNull() + { + var result = await _service.ValidateAccessTokenAsync(""); + + Assert.That(result, Is.Null, "Empty token must return null"); + } + + [Test] + public async Task ValidateAccessTokenAsync_ValidToken_ReturnsUserId() + { + // Create a valid JWT directly using the same secret key and configuration + var tokenHandler = new JwtSecurityTokenHandler(); + var key = Encoding.ASCII.GetBytes(JwtSecretKey); + var expectedUserId = Guid.NewGuid().ToString(); + + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, expectedUserId), + new Claim(ClaimTypes.Email, "test@example.com") + }), + Expires = DateTime.UtcNow.AddMinutes(60), + Issuer = JwtIssuer, + Audience = JwtAudience, + SigningCredentials = new SigningCredentials( + new SymmetricSecurityKey(key), + SecurityAlgorithms.HmacSha256Signature) + }; + + var token = tokenHandler.CreateToken(tokenDescriptor); + var tokenString = tokenHandler.WriteToken(token); + + var userId = await _service.ValidateAccessTokenAsync(tokenString); + + Assert.That(userId, Is.Not.Null, "Valid JWT must return a user ID"); + Assert.That(userId, Is.EqualTo(expectedUserId), + "ValidateAccessTokenAsync must extract the correct user ID from the JWT"); + } + + // ── ChangePasswordAsync ─────────────────────────────────────────────────── + + [Test] + public async Task ChangePasswordAsync_UserNotFound_ReturnsFalse() + { + _userRepoMock.Setup(r => r.GetUserByIdAsync(It.IsAny())) + .ReturnsAsync((User?)null); + + var result = await _service.ChangePasswordAsync("unknown-user", TestPassword, "NewSecure123!"); + + Assert.That(result, Is.False, "Password change for non-existent user must return false"); + } + + [Test] + public async Task ChangePasswordAsync_WrongCurrentPassword_ReturnsFalse() + { + var user = BuildUser(); + _userRepoMock.Setup(r => r.GetUserByIdAsync(user.UserId)).ReturnsAsync(user); + + var result = await _service.ChangePasswordAsync(user.UserId, "WrongPassword!", "NewSecure123!"); + + Assert.That(result, Is.False, "Wrong current password must return false"); + } + + [Test] + public async Task ChangePasswordAsync_WeakNewPassword_ReturnsFalse() + { + var email = UniqueEmail(); + User? registeredUser = null; + + _userRepoMock.Setup(r => r.UserExistsAsync(It.IsAny())).ReturnsAsync(false); + _userRepoMock.Setup(r => r.CreateUserAsync(It.IsAny())) + .Callback(u => registeredUser = u) + .ReturnsAsync((User u) => u); + _userRepoMock.Setup(r => r.StoreRefreshTokenAsync(It.IsAny())).Returns(Task.CompletedTask); + + await _service.RegisterAsync(BuildRegisterRequest(email), null, null); + + _userRepoMock.Setup(r => r.GetUserByIdAsync(registeredUser!.UserId)).ReturnsAsync(registeredUser); + + var result = await _service.ChangePasswordAsync(registeredUser!.UserId, TestPassword, "weak"); + + Assert.That(result, Is.False, "Weak new password must be rejected"); + } + + [Test] + public async Task ChangePasswordAsync_ValidChange_ReturnsTrue() + { + var email = UniqueEmail(); + User? registeredUser = null; + + _userRepoMock.Setup(r => r.UserExistsAsync(It.IsAny())).ReturnsAsync(false); + _userRepoMock.Setup(r => r.CreateUserAsync(It.IsAny())) + .Callback(u => registeredUser = u) + .ReturnsAsync((User u) => u); + _userRepoMock.Setup(r => r.StoreRefreshTokenAsync(It.IsAny())).Returns(Task.CompletedTask); + + await _service.RegisterAsync(BuildRegisterRequest(email), null, null); + + _userRepoMock.Setup(r => r.GetUserByIdAsync(registeredUser!.UserId)).ReturnsAsync(registeredUser); + _userRepoMock.Setup(r => r.UpdateUserAsync(It.IsAny())).Returns(Task.CompletedTask); + _userRepoMock.Setup(r => r.RevokeAllUserRefreshTokensAsync(It.IsAny())).Returns(Task.CompletedTask); + + var result = await _service.ChangePasswordAsync(registeredUser!.UserId, TestPassword, "NewSecure456@"); + + Assert.That(result, Is.True, "Valid password change must return true"); + } + + // ── VerifyDerivationAsync ───────────────────────────────────────────────── + + [Test] + public async Task VerifyDerivationAsync_UserNotFound_ReturnsFailed() + { + _userRepoMock.Setup(r => r.GetUserByIdAsync(It.IsAny())) + .ReturnsAsync((User?)null); + + var result = await _service.VerifyDerivationAsync("unknown-user", null, Guid.NewGuid().ToString()); + + Assert.That(result.Success, Is.False, "Derivation verification for unknown user must fail"); + Assert.That(result.ErrorCode, Is.EqualTo(ErrorCodes.NOT_FOUND)); + } + + [Test] + public async Task VerifyDerivationAsync_EmailMismatch_ReturnsFailed() + { + var user = BuildUser(email: "real@example.com"); + _userRepoMock.Setup(r => r.GetUserByIdAsync(user.UserId)).ReturnsAsync(user); + + var result = await _service.VerifyDerivationAsync(user.UserId, "different@example.com", Guid.NewGuid().ToString()); + + Assert.That(result.Success, Is.False, "Email mismatch must return failed derivation verification"); + Assert.That(result.ErrorCode, Is.EqualTo(ErrorCodes.FORBIDDEN)); + } + + [Test] + public async Task VerifyDerivationAsync_ValidUser_ReturnsSuccess() + { + var user = BuildUser(email: "verify@example.com"); + _userRepoMock.Setup(r => r.GetUserByIdAsync(user.UserId)).ReturnsAsync(user); + + var result = await _service.VerifyDerivationAsync(user.UserId, null, Guid.NewGuid().ToString()); + + Assert.That(result.Success, Is.True, "Valid derivation verification must succeed"); + Assert.That(result.IsConsistent, Is.True); + } + + [Test] + public async Task VerifyDerivationAsync_ValidUser_ReturnsAlgorandAddress() + { + var user = BuildUser(); + _userRepoMock.Setup(r => r.GetUserByIdAsync(user.UserId)).ReturnsAsync(user); + + var result = await _service.VerifyDerivationAsync(user.UserId, null, Guid.NewGuid().ToString()); + + Assert.That(result.AlgorandAddress, Is.EqualTo(user.AlgorandAddress), + "Verified address must match user's stored address"); + } + + [Test] + public async Task VerifyDerivationAsync_ValidUser_ReturnsDeterminismProof() + { + var user = BuildUser(); + _userRepoMock.Setup(r => r.GetUserByIdAsync(user.UserId)).ReturnsAsync(user); + + var result = await _service.VerifyDerivationAsync(user.UserId, null, Guid.NewGuid().ToString()); + + Assert.That(result.DeterminismProof, Is.Not.Null, + "Determinism proof must be returned on successful verification"); + Assert.That(result.DeterminismProof!.Standard, Is.EqualTo("ARC76")); + } + + [Test] + public async Task VerifyDerivationAsync_MatchingEmail_ReturnsSuccess() + { + var user = BuildUser(email: "match@example.com"); + _userRepoMock.Setup(r => r.GetUserByIdAsync(user.UserId)).ReturnsAsync(user); + + var result = await _service.VerifyDerivationAsync( + user.UserId, "match@example.com", Guid.NewGuid().ToString()); + + Assert.That(result.Success, Is.True, + "Verification with matching email must succeed"); + } + + [Test] + public async Task VerifyDerivationAsync_CorrelationId_EchoedBack() + { + var correlationId = Guid.NewGuid().ToString(); + var user = BuildUser(); + _userRepoMock.Setup(r => r.GetUserByIdAsync(user.UserId)).ReturnsAsync(user); + + var result = await _service.VerifyDerivationAsync(user.UserId, null, correlationId); + + Assert.That(result.CorrelationId, Is.EqualTo(correlationId), + "CorrelationId must be echoed back in the response"); + } + + // ── GetDerivationInfo ───────────────────────────────────────────────────── + + [Test] + public void GetDerivationInfo_ReturnsContractVersion() + { + var result = _service.GetDerivationInfo(Guid.NewGuid().ToString()); + + Assert.That(result.ContractVersion, Is.EqualTo(AuthenticationService.DerivationContractVersion)); + } + + [Test] + public void GetDerivationInfo_ReturnsAlgorithmDescription() + { + var result = _service.GetDerivationInfo(Guid.NewGuid().ToString()); + + Assert.That(result.AlgorithmDescription, Is.Not.Null.And.Not.Empty); + } + + [Test] + public void GetDerivationInfo_ReturnsStandardARC76() + { + var result = _service.GetDerivationInfo(Guid.NewGuid().ToString()); + + Assert.That(result.Standard, Is.EqualTo("ARC76")); + } + + [Test] + public void GetDerivationInfo_ReturnsBackwardCompatibleTrue() + { + var result = _service.GetDerivationInfo(Guid.NewGuid().ToString()); + + Assert.That(result.IsBackwardCompatible, Is.True); + } + + [Test] + public void GetDerivationInfo_CorrelationIdEchoedBack() + { + var correlationId = Guid.NewGuid().ToString(); + var result = _service.GetDerivationInfo(correlationId); + + Assert.That(result.CorrelationId, Is.EqualTo(correlationId)); + } + + [Test] + public void GetDerivationInfo_ReturnsBoundedErrorCodes() + { + var result = _service.GetDerivationInfo(Guid.NewGuid().ToString()); + + Assert.That(result.BoundedErrorCodes, Is.Not.Null.And.Not.Empty, + "Error taxonomy must be provided"); + } + + [Test] + public void GetDerivationInfo_ReturnsSpecificationUrl() + { + var result = _service.GetDerivationInfo(Guid.NewGuid().ToString()); + + Assert.That(result.SpecificationUrl, Is.Not.Null.And.Not.Empty, + "Specification URL must be provided"); + } + + // ── InspectSessionAsync ─────────────────────────────────────────────────── + + [Test] + public async Task InspectSessionAsync_UserNotFound_ReturnsInactive() + { + _userRepoMock.Setup(r => r.GetUserByIdAsync(It.IsAny())) + .ReturnsAsync((User?)null); + + var result = await _service.InspectSessionAsync("unknown-user", Guid.NewGuid().ToString()); + + Assert.That(result.IsActive, Is.False, "Session for non-existent user must be inactive"); + } + + [Test] + public async Task InspectSessionAsync_ActiveUser_ReturnsActive() + { + var user = BuildUser(isActive: true); + _userRepoMock.Setup(r => r.GetUserByIdAsync(user.UserId)).ReturnsAsync(user); + + var result = await _service.InspectSessionAsync(user.UserId, Guid.NewGuid().ToString()); + + Assert.That(result.IsActive, Is.True, "Active user must return IsActive=true"); + } + + [Test] + public async Task InspectSessionAsync_ActiveUser_ReturnsAlgorandAddress() + { + var user = BuildUser(isActive: true); + _userRepoMock.Setup(r => r.GetUserByIdAsync(user.UserId)).ReturnsAsync(user); + + var result = await _service.InspectSessionAsync(user.UserId, Guid.NewGuid().ToString()); + + Assert.That(result.AlgorandAddress, Is.EqualTo(user.AlgorandAddress), + "Session inspection must return the user's Algorand address"); + } + + [Test] + public async Task InspectSessionAsync_CorrelationId_EchoedBack() + { + var correlationId = Guid.NewGuid().ToString(); + var user = BuildUser(); + _userRepoMock.Setup(r => r.GetUserByIdAsync(user.UserId)).ReturnsAsync(user); + + var result = await _service.InspectSessionAsync(user.UserId, correlationId); + + Assert.That(result.CorrelationId, Is.EqualTo(correlationId)); + } + + // ── ARC76 determinism: 3-run repeatability via registration ─────────────── + + [Test] + public async Task RegisterAsync_SameEmailPassword_ThreeRuns_IdenticalAlgorandAddress() + { + var email = "three-run@biatec-test.example.com"; + var addresses = new List(); + + for (var run = 0; run < 3; run++) + { + _userRepoMock.Setup(r => r.UserExistsAsync(It.IsAny())).ReturnsAsync(false); + _userRepoMock.Setup(r => r.CreateUserAsync(It.IsAny())).ReturnsAsync((User u) => u); + _userRepoMock.Setup(r => r.StoreRefreshTokenAsync(It.IsAny())).Returns(Task.CompletedTask); + + var req = BuildRegisterRequest(email: email, password: TestPassword); + var result = await _service.RegisterAsync(req, null, null); + addresses.Add(result.AlgorandAddress); + } + + Assert.That(addresses.Distinct().Count(), Is.EqualTo(1), + "ARC76 must produce identical address across 3 independent registration calls (determinism proof)"); + } + + // ── Schema contract ─────────────────────────────────────────────────────── + + [Test] + public async Task RegisterAsync_Success_SchemaContractNonNullFields() + { + var req = BuildRegisterRequest(); + _userRepoMock.Setup(r => r.UserExistsAsync(It.IsAny())).ReturnsAsync(false); + _userRepoMock.Setup(r => r.CreateUserAsync(It.IsAny())).ReturnsAsync((User u) => u); + _userRepoMock.Setup(r => r.StoreRefreshTokenAsync(It.IsAny())).Returns(Task.CompletedTask); + + var result = await _service.RegisterAsync(req, null, null); + + Assert.Multiple(() => + { + Assert.That(result.UserId, Is.Not.Null, "UserId must not be null"); + Assert.That(result.Email, Is.Not.Null, "Email must not be null"); + Assert.That(result.AlgorandAddress, Is.Not.Null, "AlgorandAddress must not be null"); + Assert.That(result.AccessToken, Is.Not.Null, "AccessToken must not be null"); + Assert.That(result.RefreshToken, Is.Not.Null, "RefreshToken must not be null"); + Assert.That(result.ExpiresAt, Is.Not.Null, "ExpiresAt must not be null"); + Assert.That(result.DerivationContractVersion, Is.Not.Null, "DerivationContractVersion must not be null"); + }); + } + } +} diff --git a/BiatecTokensTests/BackendDeploymentLifecycleContractServiceUnitTests.cs b/BiatecTokensTests/BackendDeploymentLifecycleContractServiceUnitTests.cs new file mode 100644 index 00000000..ee7bd62c --- /dev/null +++ b/BiatecTokensTests/BackendDeploymentLifecycleContractServiceUnitTests.cs @@ -0,0 +1,1084 @@ +using BiatecTokensApi.Models.BackendDeploymentLifecycle; +using BiatecTokensApi.Services; +using Microsoft.Extensions.Logging; +using Moq; + +namespace BiatecTokensTests +{ + /// + /// Unit tests for . + /// + /// These tests validate: + /// - Deployment initiation with ARC76 credentials and explicit address + /// - Idempotency: repeated requests return cached results + /// - State machine: valid and invalid lifecycle transitions + /// - Validation: required fields, supported standards, network constraints + /// - ARC76 address derivation determinism + /// - Audit trail availability after deployment + /// - Error taxonomy for all failure cases + /// + [TestFixture] + public class BackendDeploymentLifecycleContractServiceUnitTests + { + private Mock> _loggerMock = null!; + private BackendDeploymentLifecycleContractService _service = null!; + + private const string TestEmail = "unit-test@biatec-test.example.com"; + private const string TestPassword = "SecurePass123!"; + // Valid Algorand test address: 58 uppercase base32 characters + private const string TestAlgorandAddress = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAY5HFKQ"; + // Valid EVM test address: 0x followed by 40 hex characters + private const string TestEvmAddress = "0x1234567890123456789012345678901234567890"; + + [SetUp] + public void SetUp() + { + _loggerMock = new Mock>(); + _service = new BackendDeploymentLifecycleContractService(_loggerMock.Object); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static BackendDeploymentContractRequest BuildARC76Request( + string? email = null, + string? idempotencyKey = null, + string network = "algorand-testnet", + string standard = "ASA") + { + return new BackendDeploymentContractRequest + { + DeployerEmail = email ?? TestEmail, + DeployerPassword = TestPassword, + TokenStandard = standard, + TokenName = "Unit Test Token", + TokenSymbol = "UTT", + Network = network, + TotalSupply = 1_000_000, + Decimals = 6, + IdempotencyKey = idempotencyKey, + CorrelationId = Guid.NewGuid().ToString() + }; + } + + private static BackendDeploymentContractRequest BuildExplicitAddressRequest( + string address = TestAlgorandAddress, + string? idempotencyKey = null) + { + return new BackendDeploymentContractRequest + { + ExplicitDeployerAddress = address, + TokenStandard = "ASA", + TokenName = "Explicit Address Token", + TokenSymbol = "EAT", + Network = "algorand-testnet", + TotalSupply = 500_000, + Decimals = 0, + IdempotencyKey = idempotencyKey, + CorrelationId = Guid.NewGuid().ToString() + }; + } + + // ── InitiateAsync: ARC76 credential path ───────────────────────────────── + + [Test] + public async Task InitiateAsync_ARC76Credentials_ReturnsSuccess() + { + var req = BuildARC76Request(); + var result = await _service.InitiateAsync(req); + + Assert.That(result, Is.Not.Null); + Assert.That(result.ErrorCode, Is.EqualTo(DeploymentErrorCode.None), + "ARC76 deployment must succeed with valid credentials"); + } + + [Test] + public async Task InitiateAsync_ARC76Credentials_ReturnsDeploymentId() + { + var req = BuildARC76Request(); + var result = await _service.InitiateAsync(req); + + Assert.That(result.DeploymentId, Is.Not.Null.And.Not.Empty, + "DeploymentId must be present for correlated status polling"); + } + + [Test] + public async Task InitiateAsync_ARC76Credentials_ReturnsIdempotencyKey() + { + var req = BuildARC76Request(); + var result = await _service.InitiateAsync(req); + + Assert.That(result.IdempotencyKey, Is.Not.Null.And.Not.Empty, + "IdempotencyKey must be reflected back in the response"); + } + + [Test] + public async Task InitiateAsync_ARC76Credentials_IsDeterministicAddress() + { + var req = BuildARC76Request(); + var result = await _service.InitiateAsync(req); + + Assert.That(result.IsDeterministicAddress, Is.True, + "ARC76 derivation must flag IsDeterministicAddress=true"); + Assert.That(result.DerivationStatus, Is.EqualTo(ARC76DerivationStatus.Derived)); + } + + [Test] + public async Task InitiateAsync_ARC76Credentials_ReturnsDeployerAddress() + { + var req = BuildARC76Request(); + var result = await _service.InitiateAsync(req); + + Assert.That(result.DeployerAddress, Is.Not.Null.And.Not.Empty, + "Deployer address must be returned for ARC76-derived deployments"); + } + + [Test] + public async Task InitiateAsync_ARC76Credentials_ReturnsCompletedState() + { + var req = BuildARC76Request(); + var result = await _service.InitiateAsync(req); + + Assert.That(result.State, Is.EqualTo(ContractLifecycleState.Completed), + "Deterministic deployment should reach Completed state synchronously"); + } + + [Test] + public async Task InitiateAsync_ARC76Credentials_ReturnsAuditEvents() + { + var req = BuildARC76Request(); + var result = await _service.InitiateAsync(req); + + Assert.That(result.AuditEvents, Is.Not.Null.And.Not.Empty, + "Audit events must be recorded for compliance tracing"); + } + + [Test] + public async Task InitiateAsync_ARC76Credentials_ReturnsValidationResults() + { + var req = BuildARC76Request(); + var result = await _service.InitiateAsync(req); + + Assert.That(result.ValidationResults, Is.Not.Null.And.Not.Empty, + "Field-level validation results must be returned"); + } + + [Test] + public async Task InitiateAsync_ARC76Credentials_ReturnsAssetId() + { + var req = BuildARC76Request(); + var result = await _service.InitiateAsync(req); + + Assert.That(result.AssetId, Is.GreaterThan(0), + "AssetId must be returned for a completed deployment"); + } + + [Test] + public async Task InitiateAsync_ARC76Credentials_ReturnsTransactionId() + { + var req = BuildARC76Request(); + var result = await _service.InitiateAsync(req); + + Assert.That(result.TransactionId, Is.Not.Null.And.Not.Empty, + "TransactionId must be returned for a completed deployment"); + } + + [Test] + public async Task InitiateAsync_ARC76Credentials_ReturnsInitiatedAt() + { + var req = BuildARC76Request(); + var result = await _service.InitiateAsync(req); + + Assert.That(result.InitiatedAt, Is.Not.Null.And.Not.Empty, + "InitiatedAt timestamp must be returned"); + } + + [Test] + public async Task InitiateAsync_ARC76Credentials_ReturnsCorrelationId() + { + var correlationId = Guid.NewGuid().ToString(); + var req = BuildARC76Request(); + req.CorrelationId = correlationId; + + var result = await _service.InitiateAsync(req); + + Assert.That(result.CorrelationId, Is.EqualTo(correlationId), + "CorrelationId must be echoed back for distributed tracing"); + } + + [Test] + public async Task InitiateAsync_ARC76SameCredentials_SameDeployerAddress() + { + var email = "determinism@biatec-test.example.com"; + var req1 = BuildARC76Request(email: email, idempotencyKey: Guid.NewGuid().ToString()); + var req2 = BuildARC76Request(email: email, idempotencyKey: Guid.NewGuid().ToString()); + + var result1 = await _service.InitiateAsync(req1); + var result2 = await _service.InitiateAsync(req2); + + Assert.That(result1.DeployerAddress, Is.EqualTo(result2.DeployerAddress), + "Same ARC76 credentials must always derive the same address (determinism)"); + } + + // ── InitiateAsync: Explicit address path ───────────────────────────────── + + [Test] + public async Task InitiateAsync_ExplicitAddress_ReturnsSuccess() + { + var req = BuildExplicitAddressRequest(); + var result = await _service.InitiateAsync(req); + + Assert.That(result.ErrorCode, Is.EqualTo(DeploymentErrorCode.None), + "Explicit address deployment must succeed"); + } + + [Test] + public async Task InitiateAsync_ExplicitAddress_DerivationStatusIsAddressProvided() + { + var req = BuildExplicitAddressRequest(); + var result = await _service.InitiateAsync(req); + + Assert.That(result.DerivationStatus, Is.EqualTo(ARC76DerivationStatus.AddressProvided)); + Assert.That(result.IsDeterministicAddress, Is.False, + "Explicit address should not be flagged as deterministic"); + } + + [Test] + public async Task InitiateAsync_ExplicitAddress_ReturnsDeployerAddress() + { + var req = BuildExplicitAddressRequest(TestAlgorandAddress); + var result = await _service.InitiateAsync(req); + + Assert.That(result.DeployerAddress, Is.EqualTo(TestAlgorandAddress)); + } // ── InitiateAsync: Null/empty request failures ──────────────────────────── + + [Test] + public async Task InitiateAsync_NullRequest_ReturnsFailed() + { + var result = await _service.InitiateAsync(null!); + + Assert.That(result.ErrorCode, Is.Not.EqualTo(DeploymentErrorCode.None), + "Null request must return an error code"); + Assert.That(result.State, Is.EqualTo(ContractLifecycleState.Failed)); + } + + [Test] + public async Task InitiateAsync_NoCredentialsNoAddress_ReturnsFailed() + { + var req = new BackendDeploymentContractRequest + { + TokenStandard = "ASA", + TokenName = "Test", + TokenSymbol = "TST", + Network = "algorand-testnet", + TotalSupply = 100 + }; + var result = await _service.InitiateAsync(req); + + Assert.That(result.ErrorCode, Is.Not.EqualTo(DeploymentErrorCode.None), + "Request without deployer credentials or address must fail"); + Assert.That(result.State, Is.EqualTo(ContractLifecycleState.Failed)); + } + + [Test] + public async Task InitiateAsync_MissingTokenStandard_ReturnsFailed() + { + var req = BuildARC76Request(); + req.TokenStandard = ""; + var result = await _service.InitiateAsync(req); + + Assert.That(result.ErrorCode, Is.EqualTo(DeploymentErrorCode.RequiredFieldMissing), + "Missing TokenStandard must return RequiredFieldMissing error"); + } + + [Test] + public async Task InitiateAsync_UnsupportedStandard_ReturnsFailed() + { + var req = BuildARC76Request(standard: "UNSUPPORTED_STANDARD"); + var result = await _service.InitiateAsync(req); + + Assert.That(result.ErrorCode, Is.EqualTo(DeploymentErrorCode.UnsupportedStandard), + "Unsupported token standard must return UnsupportedStandard error"); + } + + [Test] + public async Task InitiateAsync_MissingTokenName_ReturnsFailed() + { + var req = BuildARC76Request(); + req.TokenName = ""; + var result = await _service.InitiateAsync(req); + + Assert.That(result.ErrorCode, Is.EqualTo(DeploymentErrorCode.RequiredFieldMissing)); + } + + [Test] + public async Task InitiateAsync_TokenNameTooLong_ReturnsFailed() + { + var req = BuildARC76Request(); + req.TokenName = new string('A', 65); + var result = await _service.InitiateAsync(req); + + Assert.That(result.ErrorCode, Is.EqualTo(DeploymentErrorCode.ValidationRangeFault), + "Token name exceeding 64 chars must fail with ValidationRangeFault"); + } + + [Test] + public async Task InitiateAsync_MissingTokenSymbol_ReturnsFailed() + { + var req = BuildARC76Request(); + req.TokenSymbol = ""; + var result = await _service.InitiateAsync(req); + + Assert.That(result.ErrorCode, Is.EqualTo(DeploymentErrorCode.RequiredFieldMissing)); + } + + [Test] + public async Task InitiateAsync_TokenSymbolTooLong_ReturnsFailed() + { + var req = BuildARC76Request(); + req.TokenSymbol = "TOOLONGSY"; + var result = await _service.InitiateAsync(req); + + Assert.That(result.ErrorCode, Is.EqualTo(DeploymentErrorCode.ValidationRangeFault), + "Token symbol exceeding 8 chars must fail with ValidationRangeFault"); + } + + [Test] + public async Task InitiateAsync_MissingNetwork_ReturnsFailed() + { + var req = BuildARC76Request(); + req.Network = ""; + var result = await _service.InitiateAsync(req); + + Assert.That(result.ErrorCode, Is.EqualTo(DeploymentErrorCode.RequiredFieldMissing)); + } + + [Test] + public async Task InitiateAsync_ZeroSupply_ReturnsFailed() + { + var req = BuildARC76Request(); + req.TotalSupply = 0; + var result = await _service.InitiateAsync(req); + + Assert.That(result.ErrorCode, Is.EqualTo(DeploymentErrorCode.ValidationRangeFault), + "Zero total supply must fail with ValidationRangeFault"); + } + + [Test] + public async Task InitiateAsync_InvalidDecimalsNegative_ReturnsFailed() + { + var req = BuildARC76Request(); + req.Decimals = -1; + var result = await _service.InitiateAsync(req); + + Assert.That(result.ErrorCode, Is.EqualTo(DeploymentErrorCode.ValidationRangeFault), + "Negative decimals must fail with ValidationRangeFault"); + } + + [Test] + public async Task InitiateAsync_DecimalsTooHigh_ReturnsFailed() + { + var req = BuildARC76Request(); + req.Decimals = 20; + var result = await _service.InitiateAsync(req); + + Assert.That(result.ErrorCode, Is.EqualTo(DeploymentErrorCode.ValidationRangeFault), + "Decimals > 19 must fail with ValidationRangeFault"); + } + + // ── InitiateAsync: Idempotency ──────────────────────────────────────────── + + [Test] + public async Task InitiateAsync_SameIdempotencyKey_ReturnsIdempotentReplay() + { + var key = Guid.NewGuid().ToString(); + var req = BuildARC76Request(idempotencyKey: key); + + var first = await _service.InitiateAsync(req); + var second = await _service.InitiateAsync(req); + + Assert.That(second.IsIdempotentReplay, Is.True, + "Second request with same idempotency key must be flagged as IsIdempotentReplay=true"); + } + + [Test] + public async Task InitiateAsync_SameIdempotencyKey_ReturnsSameDeploymentId() + { + var key = Guid.NewGuid().ToString(); + var req = BuildARC76Request(idempotencyKey: key); + + var first = await _service.InitiateAsync(req); + var second = await _service.InitiateAsync(req); + + Assert.That(second.DeploymentId, Is.EqualTo(first.DeploymentId), + "Idempotent replay must return the same DeploymentId"); + } + + [Test] + public async Task InitiateAsync_DifferentIdempotencyKeys_ReturnsDifferentDeploymentIds() + { + var req1 = BuildARC76Request(idempotencyKey: Guid.NewGuid().ToString()); + var req2 = BuildARC76Request(idempotencyKey: Guid.NewGuid().ToString()); + + var result1 = await _service.InitiateAsync(req1); + var result2 = await _service.InitiateAsync(req2); + + Assert.That(result1.DeploymentId, Is.Not.EqualTo(result2.DeploymentId), + "Different idempotency keys must produce different deployment IDs"); + } + + [Test] + public async Task InitiateAsync_FirstRequest_IsNotIdempotentReplay() + { + var req = BuildARC76Request(); + var result = await _service.InitiateAsync(req); + + Assert.That(result.IsIdempotentReplay, Is.False, + "First request must NOT be flagged as IsIdempotentReplay"); + } + + // ── InitiateAsync: All supported token standards ────────────────────────── + + [TestCase("ASA")] + [TestCase("ARC3")] + [TestCase("ARC200")] + [TestCase("ARC1400")] + [TestCase("ERC20")] + public async Task InitiateAsync_SupportedStandards_AllSucceed(string standard) + { + var req = BuildARC76Request(standard: standard); + var result = await _service.InitiateAsync(req); + + Assert.That(result.ErrorCode, Is.EqualTo(DeploymentErrorCode.None), + $"Token standard '{standard}' must be supported"); + } + + // ── InitiateAsync: Multiple networks ───────────────────────────────────── + + [TestCase("algorand-testnet")] + [TestCase("algorand-mainnet")] + public async Task InitiateAsync_AlgorandNetworks_AllSucceedWithARC76(string network) + { + var req = BuildARC76Request(network: network); + var result = await _service.InitiateAsync(req); + + Assert.That(result.ErrorCode, Is.EqualTo(DeploymentErrorCode.None), + $"Algorand network '{network}' must be accepted with ARC76 credentials"); + } + + [TestCase("base-mainnet")] + [TestCase("base-sepolia")] + public async Task InitiateAsync_EvmNetworks_AllSucceedWithExplicitEvmAddress(string network) + { + var req = new BackendDeploymentContractRequest + { + ExplicitDeployerAddress = TestEvmAddress, + TokenStandard = "ERC20", + TokenName = "EVM Test Token", + TokenSymbol = "ETT", + Network = network, + TotalSupply = 1_000_000, + Decimals = 18, + CorrelationId = Guid.NewGuid().ToString() + }; + var result = await _service.InitiateAsync(req); + + Assert.That(result.ErrorCode, Is.EqualTo(DeploymentErrorCode.None), + $"EVM network '{network}' must be accepted with explicit EVM address"); + } + + // ── GetStatusAsync ──────────────────────────────────────────────────────── + + [Test] + public async Task GetStatusAsync_AfterInitiation_ReturnsStatus() + { + var req = BuildARC76Request(); + var initiated = await _service.InitiateAsync(req); + + var status = await _service.GetStatusAsync(initiated.DeploymentId); + + Assert.That(status, Is.Not.Null); + Assert.That(status.DeploymentId, Is.EqualTo(initiated.DeploymentId)); + } + + [Test] + public async Task GetStatusAsync_AfterInitiation_ReturnsSameState() + { + var req = BuildARC76Request(); + var initiated = await _service.InitiateAsync(req); + + var status = await _service.GetStatusAsync(initiated.DeploymentId); + + Assert.That(status.State, Is.EqualTo(initiated.State), + "Status query must return the same state as the initiation response"); + } + + [Test] + public async Task GetStatusAsync_UnknownDeploymentId_ReturnsFailed() + { + var status = await _service.GetStatusAsync("nonexistent-deployment-id"); + + Assert.That(status.State, Is.EqualTo(ContractLifecycleState.Failed), + "Status query for unknown ID must return Failed state"); + Assert.That(status.ErrorCode, Is.EqualTo(DeploymentErrorCode.RequiredFieldMissing)); + } + + [Test] + public async Task GetStatusAsync_EmptyDeploymentId_ReturnsFailed() + { + var status = await _service.GetStatusAsync(""); + + Assert.That(status.State, Is.EqualTo(ContractLifecycleState.Failed), + "Status query with empty ID must return Failed state"); + } + + [Test] + public async Task GetStatusAsync_WithCorrelationId_ReturnsCorrelationId() + { + var req = BuildARC76Request(); + var initiated = await _service.InitiateAsync(req); + var correlationId = Guid.NewGuid().ToString(); + + var status = await _service.GetStatusAsync(initiated.DeploymentId, correlationId); + + Assert.That(status.CorrelationId, Is.EqualTo(correlationId), + "Provided correlation ID must be echoed back"); + } + + [Test] + public async Task GetStatusAsync_WithoutCorrelationId_GeneratesCorrelationId() + { + var req = BuildARC76Request(); + var initiated = await _service.InitiateAsync(req); + + var status = await _service.GetStatusAsync(initiated.DeploymentId, null); + + Assert.That(status.CorrelationId, Is.Not.Null.And.Not.Empty, + "A correlation ID must be generated when not provided"); + } + + [Test] + public async Task GetStatusAsync_StableAcrossPolls_StateDoesNotRegress() + { + var req = BuildARC76Request(); + var initiated = await _service.InitiateAsync(req); + + var states = new List(); + for (var poll = 0; poll < 3; poll++) + { + var status = await _service.GetStatusAsync(initiated.DeploymentId); + states.Add(status.State); + } + + for (var i = 1; i < states.Count; i++) + { + Assert.That((int)states[i], Is.GreaterThanOrEqualTo((int)states[i - 1]), + "State must not regress across repeated polls"); + } + } + + // ── ValidateAsync ───────────────────────────────────────────────────────── + + [Test] + public async Task ValidateAsync_ValidARC76Request_ReturnsIsValid() + { + var req = new BackendDeploymentContractValidationRequest + { + DeployerEmail = TestEmail, + DeployerPassword = TestPassword, + TokenStandard = "ASA", + TokenName = "Validation Test Token", + TokenSymbol = "VTT", + Network = "algorand-testnet", + TotalSupply = 1_000, + Decimals = 0, + CorrelationId = Guid.NewGuid().ToString() + }; + + var result = await _service.ValidateAsync(req); + + Assert.That(result.IsValid, Is.True, + "Valid ARC76 request must pass validation"); + } + + [Test] + public async Task ValidateAsync_ValidARC76Request_DerivationStatusIsDerived() + { + var req = new BackendDeploymentContractValidationRequest + { + DeployerEmail = TestEmail, + DeployerPassword = TestPassword, + TokenStandard = "ASA", + TokenName = "Test", + TokenSymbol = "TST", + Network = "algorand-testnet", + TotalSupply = 100 + }; + + var result = await _service.ValidateAsync(req); + + Assert.That(result.DerivationStatus, Is.EqualTo(ARC76DerivationStatus.Derived)); + } + + [Test] + public async Task ValidateAsync_ValidARC76Request_ReturnsDeterministicAddressTrue() + { + var req = new BackendDeploymentContractValidationRequest + { + DeployerEmail = TestEmail, + DeployerPassword = TestPassword, + TokenStandard = "ASA", + TokenName = "Test", + TokenSymbol = "TST", + Network = "algorand-testnet", + TotalSupply = 100 + }; + + var result = await _service.ValidateAsync(req); + + Assert.That(result.IsDeterministicAddress, Is.True); + } + + [Test] + public async Task ValidateAsync_NullRequest_ReturnsInvalid() + { + var result = await _service.ValidateAsync(null!); + + Assert.That(result.IsValid, Is.False); + Assert.That(result.DerivationStatus, Is.EqualTo(ARC76DerivationStatus.Error)); + } + + [Test] + public async Task ValidateAsync_NoCredentials_ReturnsInvalid() + { + var req = new BackendDeploymentContractValidationRequest + { + TokenStandard = "ASA", + TokenName = "Test", + TokenSymbol = "TST", + Network = "algorand-testnet", + TotalSupply = 100 + }; + + var result = await _service.ValidateAsync(req); + + Assert.That(result.IsValid, Is.False, + "Validation without credentials or address must fail"); + } + + [Test] + public async Task ValidateAsync_ExplicitAddress_ReturnsAddressProvided() + { + var req = new BackendDeploymentContractValidationRequest + { + ExplicitDeployerAddress = TestAlgorandAddress, + TokenStandard = "ASA", + TokenName = "Test", + TokenSymbol = "TST", + Network = "algorand-testnet", + TotalSupply = 100 + }; + + var result = await _service.ValidateAsync(req); + + Assert.That(result.DerivationStatus, Is.EqualTo(ARC76DerivationStatus.AddressProvided)); + } + + [Test] + public async Task ValidateAsync_InvalidStandard_ReturnsInvalid() + { + var req = new BackendDeploymentContractValidationRequest + { + DeployerEmail = TestEmail, + DeployerPassword = TestPassword, + TokenStandard = "UNKNOWN", + TokenName = "Test", + TokenSymbol = "TST", + Network = "algorand-testnet", + TotalSupply = 100 + }; + + var result = await _service.ValidateAsync(req); + + Assert.That(result.IsValid, Is.False, + "Invalid standard must return IsValid=false"); + } + + [Test] + public async Task ValidateAsync_ZeroSupply_ReturnsInvalid() + { + var req = new BackendDeploymentContractValidationRequest + { + DeployerEmail = TestEmail, + DeployerPassword = TestPassword, + TokenStandard = "ASA", + TokenName = "Test", + TokenSymbol = "TST", + Network = "algorand-testnet", + TotalSupply = 0 + }; + + var result = await _service.ValidateAsync(req); + + Assert.That(result.IsValid, Is.False, + "Zero supply must return IsValid=false"); + } + + [Test] + public async Task ValidateAsync_ReturnsValidationResults() + { + var req = new BackendDeploymentContractValidationRequest + { + DeployerEmail = TestEmail, + DeployerPassword = TestPassword, + TokenStandard = "ASA", + TokenName = "Test", + TokenSymbol = "TST", + Network = "algorand-testnet", + TotalSupply = 100 + }; + + var result = await _service.ValidateAsync(req); + + Assert.That(result.ValidationResults, Is.Not.Null.And.Not.Empty, + "Field-level validation results must be returned"); + } + + [Test] + public async Task ValidateAsync_ValidRequest_ReturnsDeployerAddress() + { + var req = new BackendDeploymentContractValidationRequest + { + DeployerEmail = TestEmail, + DeployerPassword = TestPassword, + TokenStandard = "ASA", + TokenName = "Test", + TokenSymbol = "TST", + Network = "algorand-testnet", + TotalSupply = 100 + }; + + var result = await _service.ValidateAsync(req); + + Assert.That(result.DeployerAddress, Is.Not.Null.And.Not.Empty, + "Deployer address must be returned for valid ARC76 validation"); + } + + // ── GetAuditTrailAsync ──────────────────────────────────────────────────── + + [Test] + public async Task GetAuditTrailAsync_AfterDeployment_ReturnsAuditEvents() + { + var req = BuildARC76Request(); + var initiated = await _service.InitiateAsync(req); + + var trail = await _service.GetAuditTrailAsync(initiated.DeploymentId); + + Assert.That(trail, Is.Not.Null); + Assert.That(trail.Events, Is.Not.Null.And.Not.Empty, + "Audit trail must contain events after deployment"); + } + + [Test] + public async Task GetAuditTrailAsync_AfterDeployment_ContainsDeploymentInitiatedEvent() + { + var req = BuildARC76Request(); + var initiated = await _service.InitiateAsync(req); + + var trail = await _service.GetAuditTrailAsync(initiated.DeploymentId); + + Assert.That( + trail.Events.Any(e => e.EventKind == ComplianceAuditEventKind.DeploymentInitiated), + Is.True, + "Audit trail must contain a DeploymentInitiated event"); + } + + [Test] + public async Task GetAuditTrailAsync_AfterDeployment_ContainsDeploymentCompletedEvent() + { + var req = BuildARC76Request(); + var initiated = await _service.InitiateAsync(req); + + var trail = await _service.GetAuditTrailAsync(initiated.DeploymentId); + + Assert.That( + trail.Events.Any(e => e.EventKind == ComplianceAuditEventKind.DeploymentCompleted), + Is.True, + "Audit trail must contain a DeploymentCompleted event for successful deployments"); + } + + [Test] + public async Task GetAuditTrailAsync_AfterARC76Deployment_ContainsAccountDerivedEvent() + { + var req = BuildARC76Request(); + var initiated = await _service.InitiateAsync(req); + + var trail = await _service.GetAuditTrailAsync(initiated.DeploymentId); + + Assert.That( + trail.Events.Any(e => e.EventKind == ComplianceAuditEventKind.AccountDerived), + Is.True, + "ARC76 deployment audit trail must contain AccountDerived event"); + } + + [Test] + public async Task GetAuditTrailAsync_AfterDeployment_FinalStateIsCompleted() + { + var req = BuildARC76Request(); + var initiated = await _service.InitiateAsync(req); + + var trail = await _service.GetAuditTrailAsync(initiated.DeploymentId); + + Assert.That(trail.FinalState, Is.EqualTo(ContractLifecycleState.Completed)); + } + + [Test] + public async Task GetAuditTrailAsync_UnknownDeploymentId_ReturnsEmptyTrail() + { + var trail = await _service.GetAuditTrailAsync("unknown-deployment-id"); + + Assert.That(trail, Is.Not.Null); + Assert.That(trail.Events, Is.Empty, + "Audit trail for unknown deployment must return empty event list"); + } + + [Test] + public async Task GetAuditTrailAsync_AfterDeployment_DeploymentIdMatchesRequest() + { + var req = BuildARC76Request(); + var initiated = await _service.InitiateAsync(req); + + var trail = await _service.GetAuditTrailAsync(initiated.DeploymentId); + + Assert.That(trail.DeploymentId, Is.EqualTo(initiated.DeploymentId)); + } + + [Test] + public async Task GetAuditTrailAsync_WithCorrelationId_EchoesCorrelationId() + { + var req = BuildARC76Request(); + var initiated = await _service.InitiateAsync(req); + var correlationId = Guid.NewGuid().ToString(); + + var trail = await _service.GetAuditTrailAsync(initiated.DeploymentId, correlationId); + + Assert.That(trail.CorrelationId, Is.EqualTo(correlationId)); + } + + // ── DeriveARC76Address ──────────────────────────────────────────────────── + + [Test] + public void DeriveARC76Address_ValidCredentials_ReturnsDeterministicAddress() + { + var address1 = _service.DeriveARC76Address(TestEmail, TestPassword); + var address2 = _service.DeriveARC76Address(TestEmail, TestPassword); + + Assert.That(address1, Is.EqualTo(address2), + "Same credentials must always produce the same Algorand address"); + } + + [Test] + public void DeriveARC76Address_ValidCredentials_ReturnsAlgorandAddress() + { + var address = _service.DeriveARC76Address(TestEmail, TestPassword); + + Assert.That(address, Is.Not.Null.And.Not.Empty); + Assert.That(address.Length, Is.EqualTo(58), + "Algorand address must be 58 characters"); + } + + [Test] + public void DeriveARC76Address_DifferentEmails_ReturnsDifferentAddresses() + { + var address1 = _service.DeriveARC76Address("user1@example.com", TestPassword); + var address2 = _service.DeriveARC76Address("user2@example.com", TestPassword); + + Assert.That(address1, Is.Not.EqualTo(address2), + "Different email inputs must produce different addresses"); + } + + [Test] + public void DeriveARC76Address_DifferentPasswords_ReturnsDifferentAddresses() + { + var address1 = _service.DeriveARC76Address(TestEmail, "Password1!"); + var address2 = _service.DeriveARC76Address(TestEmail, "Password2!"); + + Assert.That(address1, Is.Not.EqualTo(address2), + "Different password inputs must produce different addresses"); + } + + [Test] + public void DeriveARC76Address_EmptyEmail_ThrowsArgumentException() + { + Assert.Throws(() => _service.DeriveARC76Address("", TestPassword), + "Empty email must throw ArgumentException"); + } + + [Test] + public void DeriveARC76Address_EmptyPassword_ThrowsArgumentException() + { + Assert.Throws(() => _service.DeriveARC76Address(TestEmail, ""), + "Empty password must throw ArgumentException"); + } + + [Test] + public void DeriveARC76Address_WhitespaceEmail_ThrowsArgumentException() + { + Assert.Throws(() => _service.DeriveARC76Address(" ", TestPassword), + "Whitespace-only email must throw ArgumentException"); + } + + [Test] + public void DeriveARC76Address_CaseInsensitiveEmail_ReturnsSameAddress() + { + var address1 = _service.DeriveARC76Address("USER@EXAMPLE.COM", TestPassword); + var address2 = _service.DeriveARC76Address("user@example.com", TestPassword); + + Assert.That(address1, Is.EqualTo(address2), + "Email canonicalization must make derivation case-insensitive"); + } + + // ── IsValidStateTransition ──────────────────────────────────────────────── + + [Test] + public void IsValidStateTransition_PendingToValidated_IsValid() + { + Assert.That( + _service.IsValidStateTransition(ContractLifecycleState.Pending, ContractLifecycleState.Validated), + Is.True); + } + + [Test] + public void IsValidStateTransition_PendingToFailed_IsValid() + { + Assert.That( + _service.IsValidStateTransition(ContractLifecycleState.Pending, ContractLifecycleState.Failed), + Is.True); + } + + [Test] + public void IsValidStateTransition_PendingToCancelled_IsValid() + { + Assert.That( + _service.IsValidStateTransition(ContractLifecycleState.Pending, ContractLifecycleState.Cancelled), + Is.True); + } + + [Test] + public void IsValidStateTransition_ValidatedToSubmitted_IsValid() + { + Assert.That( + _service.IsValidStateTransition(ContractLifecycleState.Validated, ContractLifecycleState.Submitted), + Is.True); + } + + [Test] + public void IsValidStateTransition_SubmittedToConfirmed_IsValid() + { + Assert.That( + _service.IsValidStateTransition(ContractLifecycleState.Submitted, ContractLifecycleState.Confirmed), + Is.True); + } + + [Test] + public void IsValidStateTransition_ConfirmedToCompleted_IsValid() + { + Assert.That( + _service.IsValidStateTransition(ContractLifecycleState.Confirmed, ContractLifecycleState.Completed), + Is.True); + } + + [Test] + public void IsValidStateTransition_FailedToPending_IsValid_Retry() + { + Assert.That( + _service.IsValidStateTransition(ContractLifecycleState.Failed, ContractLifecycleState.Pending), + Is.True, + "Failed → Pending is valid for retry"); + } + + [Test] + public void IsValidStateTransition_CompletedToAny_IsInvalid_Terminal() + { + foreach (var target in Enum.GetValues()) + { + Assert.That( + _service.IsValidStateTransition(ContractLifecycleState.Completed, target), + Is.False, + $"Completed is a terminal state; Completed → {target} must be invalid"); + } + } + + [Test] + public void IsValidStateTransition_CancelledToAny_IsInvalid_Terminal() + { + foreach (var target in Enum.GetValues()) + { + Assert.That( + _service.IsValidStateTransition(ContractLifecycleState.Cancelled, target), + Is.False, + $"Cancelled is a terminal state; Cancelled → {target} must be invalid"); + } + } + + [Test] + public void IsValidStateTransition_PendingToCompleted_IsInvalid_SkipsStages() + { + Assert.That( + _service.IsValidStateTransition(ContractLifecycleState.Pending, ContractLifecycleState.Completed), + Is.False, + "Skipping intermediate states must be invalid"); + } + + [Test] + public void IsValidStateTransition_PendingToPending_IsInvalid_SameState() + { + Assert.That( + _service.IsValidStateTransition(ContractLifecycleState.Pending, ContractLifecycleState.Pending), + Is.False, + "Self-transition to the same state must be invalid"); + } + + // ── ARC76 determinism: 3-run repeatability ──────────────────────────────── + + [Test] + public async Task InitiateAsync_ThreeRunsIdenticalRequest_IdenticalDeployerAddress() + { + var email = "repeatability@biatec-test.example.com"; + var addresses = new List(); + + for (var run = 0; run < 3; run++) + { + var req = BuildARC76Request( + email: email, + idempotencyKey: Guid.NewGuid().ToString()); + var result = await _service.InitiateAsync(req); + addresses.Add(result.DeployerAddress); + } + + Assert.That(addresses.Distinct().Count(), Is.EqualTo(1), + "DeployerAddress must be identical across 3 independent runs (ARC76 determinism)"); + } + + // ── Schema contract: required fields must be non-null on success ────────── + + [Test] + public async Task InitiateAsync_SuccessfulDeployment_SchemaContractNonNullFields() + { + var req = BuildARC76Request(); + var result = await _service.InitiateAsync(req); + + // Required fields per frontend contract + Assert.Multiple(() => + { + Assert.That(result.DeploymentId, Is.Not.Null, "DeploymentId must not be null"); + Assert.That(result.IdempotencyKey, Is.Not.Null, "IdempotencyKey must not be null"); + Assert.That(result.CorrelationId, Is.Not.Null, "CorrelationId must not be null"); + Assert.That(result.InitiatedAt, Is.Not.Null, "InitiatedAt must not be null"); + Assert.That(result.LastUpdatedAt, Is.Not.Null, "LastUpdatedAt must not be null"); + Assert.That(result.DeployerAddress, Is.Not.Null, "DeployerAddress must not be null"); + Assert.That(result.ValidationResults, Is.Not.Null, "ValidationResults must not be null"); + Assert.That(result.AuditEvents, Is.Not.Null, "AuditEvents must not be null"); + }); + } + } +}