using System.Text; using Bit.Core.Auth.Identity.TokenProviders; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Options; using NSubstitute; using Xunit; namespace Bit.Core.Test.Auth.Identity; [SutProviderCustomize] public class OtpTokenProviderTests { private readonly string _defaultTokenProviderName = "DefaultOtpProvider"; private readonly DefaultOtpTokenProviderOptions _defaultOtpTokenProviderOptions = new() { TokenLength = 6, TokenAlpha = false, TokenNumeric = true }; [Theory, BitAutoData] public async Task GenerateTokenAsync_Success_ReturnsToken( SutProvider> sutProvider, string purpose, string uniqueIdentifier) { // Arrange sutProvider.GetDependency>() .Value.Returns(_defaultOtpTokenProviderOptions); sutProvider.Create(); // Act var result = await sutProvider.Sut.GenerateTokenAsync(_defaultTokenProviderName, purpose, uniqueIdentifier); // Assert Assert.NotNull(result); Assert.NotEmpty(result); Assert.Equal(6, result.Length); // Default length Assert.True(result.All(char.IsDigit)); // Default is numeric only // Verify cache was called with correct key var expectedCacheKey = $"{_defaultTokenProviderName}_{purpose}_{uniqueIdentifier}"; await sutProvider.GetDependency() .Received(1) .SetAsync(expectedCacheKey, Arg.Any(), Arg.Any()); } [Theory, BitAutoData] public async Task GenerateTokenAsync_CustomConfiguration_ReturnsCorrectFormat( SutProvider> sutProvider, string tokenProviderName, string purpose, string uniqueIdentifier) { // Arrange var otpConfig = new DefaultOtpTokenProviderOptions { TokenLength = 8, TokenAlpha = true, TokenNumeric = true }; sutProvider.GetDependency>() .Value.Returns(otpConfig); sutProvider.Create(); // Act var result = await sutProvider.Sut.GenerateTokenAsync(tokenProviderName, purpose, uniqueIdentifier); // Assert Assert.NotNull(result); Assert.Equal(8, result.Length); Assert.Contains(result, char.IsLetterOrDigit); } [Theory, BitAutoData] public async Task GenerateTokenAsync_NumericOnly_ReturnsOnlyDigits( SutProvider> sutProvider, string purpose, string uniqueIdentifier) { // Arrange var otpConfig = new DefaultOtpTokenProviderOptions { TokenLength = 10, TokenAlpha = false, TokenNumeric = true }; sutProvider.GetDependency>() .Value.Returns(otpConfig); sutProvider.Create(); // Act var result = await sutProvider.Sut.GenerateTokenAsync(_defaultTokenProviderName, purpose, uniqueIdentifier); // Assert Assert.Equal(10, result.Length); Assert.True(result.All(char.IsDigit)); } [Theory, BitAutoData] public async Task ValidateTokenAsync_ValidToken_ReturnsTrue( SutProvider> sutProvider, string purpose, string uniqueIdentifier, string token) { // Arrange var expectedCacheKey = $"{_defaultTokenProviderName}_{purpose}_{uniqueIdentifier}"; var tokenBytes = Encoding.UTF8.GetBytes(token); sutProvider.GetDependency() .GetAsync(expectedCacheKey) .Returns(tokenBytes); // Act var result = await sutProvider.Sut.ValidateTokenAsync(token, _defaultTokenProviderName, purpose, uniqueIdentifier); // Assert Assert.True(result); // Verify token was removed from cache after successful validation await sutProvider.GetDependency() .Received(1) .RemoveAsync(expectedCacheKey); } [Theory, BitAutoData] public async Task ValidateTokenAsync_InvalidToken_ReturnsFalse( SutProvider> sutProvider, string purpose, string uniqueIdentifier, string token, string wrongToken) { // Arrange var expectedCacheKey = $"{_defaultTokenProviderName}_{purpose}_{uniqueIdentifier}"; var tokenBytes = Encoding.UTF8.GetBytes(wrongToken); // Different token in cache sutProvider.GetDependency() .GetAsync(expectedCacheKey) .Returns(tokenBytes); // Act var result = await sutProvider.Sut.ValidateTokenAsync(token, _defaultTokenProviderName, purpose, uniqueIdentifier); // Assert Assert.False(result); // Verify token was NOT removed from cache for invalid validation await sutProvider.GetDependency() .DidNotReceive() .RemoveAsync(expectedCacheKey); } [Theory, BitAutoData] public async Task ValidateTokenAsync_TokenNotFound_ReturnsFalse( SutProvider> sutProvider, string purpose, string uniqueIdentifier, string token) { // Arrange var expectedCacheKey = $"{_defaultTokenProviderName}_{purpose}_{uniqueIdentifier}"; sutProvider.GetDependency() .GetAsync(expectedCacheKey) .Returns((byte[])null); // Token not found in cache // Act var result = await sutProvider.Sut.ValidateTokenAsync(token, _defaultTokenProviderName, purpose, uniqueIdentifier); // Assert Assert.False(result); // Verify removal was not attempted await sutProvider.GetDependency() .DidNotReceive() .RemoveAsync(Arg.Any()); } [Theory, BitAutoData] public async Task ValidateTokenAsync_EmptyToken_ReturnsFalse( SutProvider> sutProvider, string purpose, string uniqueIdentifier) { // Act var result = await sutProvider.Sut.ValidateTokenAsync("", _defaultTokenProviderName, purpose, uniqueIdentifier); // Assert Assert.False(result); } [Theory, BitAutoData] public async Task ValidateTokenAsync_NullToken_ReturnsFalse( SutProvider> sutProvider, string purpose, string uniqueIdentifier) { // Act var result = await sutProvider.Sut.ValidateTokenAsync(null, _defaultTokenProviderName, purpose, uniqueIdentifier); // Assert Assert.False(result); } // Tests for null/empty purpose and uniqueIdentifier parameters [Theory, BitAutoData] public async Task GenerateTokenAsync_NullPurpose_ReturnsNull( SutProvider> sutProvider, string uniqueIdentifier) { // Act var result = await sutProvider.Sut.GenerateTokenAsync(_defaultTokenProviderName, null, uniqueIdentifier); // Assert Assert.Null(result); // Verify cache was not called await sutProvider.GetDependency() .DidNotReceive() .SetAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Theory, BitAutoData] public async Task GenerateTokenAsync_EmptyPurpose_ReturnsNull( SutProvider> sutProvider, string uniqueIdentifier) { // Act var result = await sutProvider.Sut.GenerateTokenAsync(_defaultTokenProviderName, "", uniqueIdentifier); // Assert Assert.Null(result); // Verify cache was not called await sutProvider.GetDependency() .DidNotReceive() .SetAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Theory, BitAutoData] public async Task GenerateTokenAsync_NullUniqueIdentifier_ReturnsNull( SutProvider> sutProvider, string purpose) { // Act var result = await sutProvider.Sut.GenerateTokenAsync(_defaultTokenProviderName, purpose, null); // Assert Assert.Null(result); // Verify cache was not called await sutProvider.GetDependency() .DidNotReceive() .SetAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Theory, BitAutoData] public async Task GenerateTokenAsync_EmptyUniqueIdentifier_ReturnsNull( SutProvider> sutProvider, string purpose) { // Act var result = await sutProvider.Sut.GenerateTokenAsync(_defaultTokenProviderName, purpose, ""); // Assert Assert.Null(result); // Verify cache was not called await sutProvider.GetDependency() .DidNotReceive() .SetAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Theory, BitAutoData] public async Task ValidateTokenAsync_NullPurpose_ReturnsFalse( SutProvider> sutProvider, string token, string uniqueIdentifier) { // Act var result = await sutProvider.Sut.ValidateTokenAsync(token, _defaultTokenProviderName, null, uniqueIdentifier); // Assert Assert.False(result); // Verify cache was not called await sutProvider.GetDependency() .DidNotReceive() .GetAsync(Arg.Any()); } [Theory, BitAutoData] public async Task ValidateTokenAsync_EmptyPurpose_ReturnsFalse( SutProvider> sutProvider, string token, string uniqueIdentifier) { // Act var result = await sutProvider.Sut.ValidateTokenAsync(token, _defaultTokenProviderName, "", uniqueIdentifier); // Assert Assert.False(result); // Verify cache was not called await sutProvider.GetDependency() .DidNotReceive() .GetAsync(Arg.Any()); } [Theory, BitAutoData] public async Task ValidateTokenAsync_NullUniqueIdentifier_ReturnsFalse( SutProvider> sutProvider, string token, string purpose) { // Act var result = await sutProvider.Sut.ValidateTokenAsync(token, _defaultTokenProviderName, purpose, null); // Assert Assert.False(result); // Verify cache was not called await sutProvider.GetDependency() .DidNotReceive() .GetAsync(Arg.Any()); } [Theory, BitAutoData] public async Task ValidateTokenAsync_EmptyUniqueIdentifier_ReturnsFalse( SutProvider> sutProvider, string token, string purpose) { // Act var result = await sutProvider.Sut.ValidateTokenAsync(token, _defaultTokenProviderName, purpose, ""); // Assert Assert.False(result); // Verify cache was not called await sutProvider.GetDependency() .DidNotReceive() .GetAsync(Arg.Any()); } [Theory, BitAutoData] public async Task GenerateTokenAsync_OverwritesExistingToken( SutProvider> sutProvider, string purpose, string uniqueIdentifier) { // Arrange sutProvider.GetDependency>() .Value.Returns(_defaultOtpTokenProviderOptions); sutProvider.Create(); // Act - Generate token twice with same parameters var firstToken = await sutProvider.Sut.GenerateTokenAsync(_defaultTokenProviderName, purpose, uniqueIdentifier); var secondToken = await sutProvider.Sut.GenerateTokenAsync(_defaultTokenProviderName, purpose, uniqueIdentifier); // Assert Assert.NotEqual(firstToken, secondToken); // Should be different tokens var expectedCacheKey = $"{_defaultTokenProviderName}_{purpose}_{uniqueIdentifier}"; await sutProvider.GetDependency() .Received(2) // Called twice - once for each generation .SetAsync(expectedCacheKey, Arg.Any(), Arg.Any()); } [Theory, BitAutoData] public async Task CacheKeyFormat_IsCorrect( SutProvider> sutProvider, string purpose, string uniqueIdentifier) { // Arrange sutProvider.GetDependency>() .Value.Returns(_defaultOtpTokenProviderOptions); sutProvider.Create(); // Act await sutProvider.Sut.GenerateTokenAsync(_defaultTokenProviderName, purpose, uniqueIdentifier); // Assert var expectedCacheKey = $"{_defaultTokenProviderName}_{purpose}_{uniqueIdentifier}"; await sutProvider.GetDependency() .Received(1) .SetAsync(expectedCacheKey, Arg.Any(), Arg.Any()); } [Theory, BitAutoData] public async Task ValidateTokenAsync_CaseSensitive( SutProvider> sutProvider, string purpose, string uniqueIdentifier) { // Arrange var token = "ABC123"; var expectedCacheKey = $"{_defaultTokenProviderName}_{purpose}_{uniqueIdentifier}"; var tokenBytes = Encoding.UTF8.GetBytes(token); sutProvider.GetDependency() .GetAsync(expectedCacheKey) .Returns(tokenBytes); // Act & Assert var validResult = await sutProvider.Sut.ValidateTokenAsync("ABC123", _defaultTokenProviderName, purpose, uniqueIdentifier); Assert.True(validResult); // Reset the cache mock to return the token again sutProvider.GetDependency() .GetAsync(expectedCacheKey) .Returns(tokenBytes); var invalidResult = await sutProvider.Sut.ValidateTokenAsync("abc123", _defaultTokenProviderName, purpose, uniqueIdentifier); Assert.False(invalidResult); } [Theory, BitAutoData] public async Task RoundTrip_GenerateAndValidate_Success( SutProvider> sutProvider, string purpose, string uniqueIdentifier) { // Arrange sutProvider.GetDependency>() .Value.Returns(_defaultOtpTokenProviderOptions); sutProvider.Create(); var expectedCacheKey = $"{_defaultTokenProviderName}_{purpose}_{uniqueIdentifier}"; byte[] storedToken = null; // Setup cache to capture stored token and return it on get sutProvider.GetDependency() .When(x => x.SetAsync(expectedCacheKey, Arg.Any(), Arg.Any())) .Do(callInfo => storedToken = callInfo.ArgAt(1)); sutProvider.GetDependency() .GetAsync(expectedCacheKey) .Returns(callInfo => storedToken); // Act var generatedToken = await sutProvider.Sut.GenerateTokenAsync(_defaultTokenProviderName, purpose, uniqueIdentifier); var isValid = await sutProvider.Sut.ValidateTokenAsync(generatedToken, _defaultTokenProviderName, purpose, uniqueIdentifier); // Assert Assert.True(isValid); Assert.NotNull(generatedToken); Assert.NotEmpty(generatedToken); } }