From 6e97ceb0df027dbce34074208df33f196f376ef1 Mon Sep 17 00:00:00 2001 From: maliming Date: Tue, 24 Feb 2026 08:49:39 +0800 Subject: [PATCH] Refactor token provider tests to use a common base class for single active token providers and improve token verification logic --- .../AbpSingleActiveTokenProvider.cs | 13 +- .../AbpChangeEmailTokenProvider_Tests.cs | 139 ++--------------- ...AbpEmailConfirmationTokenProvider_Tests.cs | 145 +++--------------- .../AbpPasswordResetTokenProvider_Tests.cs | 139 ++--------------- .../AbpSingleActiveTokenProviderTestBase.cs | 138 +++++++++++++++++ 5 files changed, 200 insertions(+), 374 deletions(-) create mode 100644 modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProviderTestBase.cs diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProvider.cs b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProvider.cs index baf8e4bffd..511b2f251a 100644 --- a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProvider.cs +++ b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProvider.cs @@ -62,7 +62,18 @@ public abstract class AbpSingleActiveTokenProvider : DataProtectorTokenProvider< } var inputHash = ComputeSha256Hash(token); - return string.Equals(storedHash, inputHash, StringComparison.Ordinal); + try + { + var storedHashBytes = Convert.FromHexString(storedHash); + var inputHashBytes = Convert.FromHexString(inputHash); + return CryptographicOperations.FixedTimeEquals(storedHashBytes, inputHashBytes); + } + catch (FormatException) + { + // In case the stored hash is corrupted or not a valid hex string, + // treat the token as invalid rather than throwing. + return false; + } } protected virtual string ComputeSha256Hash(string input) diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpChangeEmailTokenProvider_Tests.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpChangeEmailTokenProvider_Tests.cs index f72994d582..dee4ebe068 100644 --- a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpChangeEmailTokenProvider_Tests.cs +++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpChangeEmailTokenProvider_Tests.cs @@ -7,22 +7,25 @@ using Xunit; namespace Volo.Abp.Identity.AspNetCore; -public class AbpChangeEmailTokenProvider_Tests : AbpIdentityAspNetCoreTestBase +public class AbpChangeEmailTokenProvider_Tests : AbpSingleActiveTokenProviderTestBase { private const string NewEmail = "newemail@example.com"; - protected IIdentityUserRepository UserRepository { get; } - protected IdentityUserManager UserManager { get; } - protected IdentityTestData TestData { get; } - protected IUnitOfWorkManager UnitOfWorkManager { get; } + protected override Task GenerateTokenAsync(IdentityUser user) + => UserManager.GenerateChangeEmailTokenAsync(user, NewEmail); - public AbpChangeEmailTokenProvider_Tests() - { - UserRepository = GetRequiredService(); - UserManager = GetRequiredService(); - TestData = GetRequiredService(); - UnitOfWorkManager = GetRequiredService(); - } + protected override Task VerifyTokenAsync(IdentityUser user, string token) + => UserManager.VerifyUserTokenAsync( + user, + UserManager.Options.Tokens.ChangeEmailTokenProvider, + UserManager.GetChangeEmailTokenPurpose(NewEmail), + token); + + protected override string GetProviderName() + => UserManager.Options.Tokens.ChangeEmailTokenProvider; + + protected override string GetPurpose() + => UserManager.GetChangeEmailTokenPurpose(NewEmail); [Fact] public void AbpChangeEmailTokenProvider_Should_Be_Registered() @@ -42,82 +45,6 @@ public class AbpChangeEmailTokenProvider_Tests : AbpIdentityAspNetCoreTestBase identityOptions.Tokens.ChangeEmailTokenProvider.ShouldBe(AbpChangeEmailTokenProvider.ProviderName); } - [Fact] - public async Task Generate_And_Verify_Change_Email_Token_Should_Succeed() - { - using (var uow = UnitOfWorkManager.Begin()) - { - var john = await UserRepository.GetAsync(TestData.UserJohnId); - - var token = await UserManager.GenerateChangeEmailTokenAsync(john, NewEmail); - token.ShouldNotBeNullOrEmpty(); - - var isValid = await UserManager.VerifyUserTokenAsync( - john, - UserManager.Options.Tokens.ChangeEmailTokenProvider, - UserManager.GetChangeEmailTokenPurpose(NewEmail), - token); - - isValid.ShouldBeTrue(); - - await uow.CompleteAsync(); - } - } - - [Fact] - public async Task Invalid_Token_Should_Fail_Verification() - { - using (var uow = UnitOfWorkManager.Begin()) - { - var john = await UserRepository.GetAsync(TestData.UserJohnId); - - await UserManager.GenerateChangeEmailTokenAsync(john, NewEmail); - - var isValid = await UserManager.VerifyUserTokenAsync( - john, - UserManager.Options.Tokens.ChangeEmailTokenProvider, - UserManager.GetChangeEmailTokenPurpose(NewEmail), - "invalid-token-value"); - - isValid.ShouldBeFalse(); - - await uow.CompleteAsync(); - } - } - - [Fact] - public async Task Second_Token_Should_Invalidate_First_Token() - { - using (var uow = UnitOfWorkManager.Begin()) - { - var john = await UserRepository.GetAsync(TestData.UserJohnId); - - var firstToken = await UserManager.GenerateChangeEmailTokenAsync(john, NewEmail); - - john = await UserRepository.GetAsync(TestData.UserJohnId); - var secondToken = await UserManager.GenerateChangeEmailTokenAsync(john, NewEmail); - - john = await UserRepository.GetAsync(TestData.UserJohnId); - - var firstTokenValid = await UserManager.VerifyUserTokenAsync( - john, - UserManager.Options.Tokens.ChangeEmailTokenProvider, - UserManager.GetChangeEmailTokenPurpose(NewEmail), - firstToken); - - var secondTokenValid = await UserManager.VerifyUserTokenAsync( - john, - UserManager.Options.Tokens.ChangeEmailTokenProvider, - UserManager.GetChangeEmailTokenPurpose(NewEmail), - secondToken); - - firstTokenValid.ShouldBeFalse(); - secondTokenValid.ShouldBeTrue(); - - await uow.CompleteAsync(); - } - } - [Fact] public async Task Token_Should_Become_Invalid_After_Email_Change() { @@ -133,42 +60,8 @@ public class AbpChangeEmailTokenProvider_Tests : AbpIdentityAspNetCoreTestBase // SecurityStamp has changed after the email change, so the old token must be invalid. john = await UserRepository.GetAsync(TestData.UserJohnId); - var isValid = await UserManager.VerifyUserTokenAsync( - john, - UserManager.Options.Tokens.ChangeEmailTokenProvider, - UserManager.GetChangeEmailTokenPurpose(NewEmail), - token); - - isValid.ShouldBeFalse(); - - await uow.CompleteAsync(); - } - } - - [Fact] - public async Task Token_Hash_Should_Persist_Across_UnitOfWork_Boundaries() - { - string token; - - // UoW 1: generate the token; UpdateAsync inside GenerateAsync must persist the hash. - using (var uow = UnitOfWorkManager.Begin(requiresNew: true)) - { - var john = await UserRepository.GetAsync(TestData.UserJohnId); - token = await UserManager.GenerateChangeEmailTokenAsync(john, NewEmail); - await uow.CompleteAsync(); - } - - // UoW 2: validate using a fresh DbContext to confirm the hash was written to the DB. - using (var uow = UnitOfWorkManager.Begin(requiresNew: true)) - { - var john = await UserRepository.GetAsync(TestData.UserJohnId); - var isValid = await UserManager.VerifyUserTokenAsync( - john, - UserManager.Options.Tokens.ChangeEmailTokenProvider, - UserManager.GetChangeEmailTokenPurpose(NewEmail), - token); + (await VerifyTokenAsync(john, token)).ShouldBeFalse(); - isValid.ShouldBeTrue(); await uow.CompleteAsync(); } } diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpEmailConfirmationTokenProvider_Tests.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpEmailConfirmationTokenProvider_Tests.cs index b285a5e6d7..fe8d5c7de8 100644 --- a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpEmailConfirmationTokenProvider_Tests.cs +++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpEmailConfirmationTokenProvider_Tests.cs @@ -7,20 +7,23 @@ using Xunit; namespace Volo.Abp.Identity.AspNetCore; -public class AbpEmailConfirmationTokenProvider_Tests : AbpIdentityAspNetCoreTestBase +public class AbpEmailConfirmationTokenProvider_Tests : AbpSingleActiveTokenProviderTestBase { - protected IIdentityUserRepository UserRepository { get; } - protected IdentityUserManager UserManager { get; } - protected IdentityTestData TestData { get; } - protected IUnitOfWorkManager UnitOfWorkManager { get; } + protected override Task GenerateTokenAsync(IdentityUser user) + => UserManager.GenerateEmailConfirmationTokenAsync(user); - public AbpEmailConfirmationTokenProvider_Tests() - { - UserRepository = GetRequiredService(); - UserManager = GetRequiredService(); - TestData = GetRequiredService(); - UnitOfWorkManager = GetRequiredService(); - } + protected override Task VerifyTokenAsync(IdentityUser user, string token) + => UserManager.VerifyUserTokenAsync( + user, + UserManager.Options.Tokens.EmailConfirmationTokenProvider, + UserManager.ConfirmEmailTokenPurpose, + token); + + protected override string GetProviderName() + => UserManager.Options.Tokens.EmailConfirmationTokenProvider; + + protected override string GetPurpose() + => UserManager.ConfirmEmailTokenPurpose; [Fact] public void AbpEmailConfirmationTokenProvider_Should_Be_Registered() @@ -40,82 +43,6 @@ public class AbpEmailConfirmationTokenProvider_Tests : AbpIdentityAspNetCoreTest identityOptions.Tokens.EmailConfirmationTokenProvider.ShouldBe(AbpEmailConfirmationTokenProvider.ProviderName); } - [Fact] - public async Task Generate_And_Verify_Email_Confirmation_Token_Should_Succeed() - { - using (var uow = UnitOfWorkManager.Begin()) - { - var john = await UserRepository.GetAsync(TestData.UserJohnId); - - var token = await UserManager.GenerateEmailConfirmationTokenAsync(john); - token.ShouldNotBeNullOrEmpty(); - - var isValid = await UserManager.VerifyUserTokenAsync( - john, - UserManager.Options.Tokens.EmailConfirmationTokenProvider, - UserManager.ConfirmEmailTokenPurpose, - token); - - isValid.ShouldBeTrue(); - - await uow.CompleteAsync(); - } - } - - [Fact] - public async Task Invalid_Token_Should_Fail_Verification() - { - using (var uow = UnitOfWorkManager.Begin()) - { - var john = await UserRepository.GetAsync(TestData.UserJohnId); - - await UserManager.GenerateEmailConfirmationTokenAsync(john); - - var isValid = await UserManager.VerifyUserTokenAsync( - john, - UserManager.Options.Tokens.EmailConfirmationTokenProvider, - UserManager.ConfirmEmailTokenPurpose, - "invalid-token-value"); - - isValid.ShouldBeFalse(); - - await uow.CompleteAsync(); - } - } - - [Fact] - public async Task Second_Token_Should_Invalidate_First_Token() - { - using (var uow = UnitOfWorkManager.Begin()) - { - var john = await UserRepository.GetAsync(TestData.UserJohnId); - - var firstToken = await UserManager.GenerateEmailConfirmationTokenAsync(john); - - john = await UserRepository.GetAsync(TestData.UserJohnId); - var secondToken = await UserManager.GenerateEmailConfirmationTokenAsync(john); - - john = await UserRepository.GetAsync(TestData.UserJohnId); - - var firstTokenValid = await UserManager.VerifyUserTokenAsync( - john, - UserManager.Options.Tokens.EmailConfirmationTokenProvider, - UserManager.ConfirmEmailTokenPurpose, - firstToken); - - var secondTokenValid = await UserManager.VerifyUserTokenAsync( - john, - UserManager.Options.Tokens.EmailConfirmationTokenProvider, - UserManager.ConfirmEmailTokenPurpose, - secondToken); - - firstTokenValid.ShouldBeFalse(); - secondTokenValid.ShouldBeTrue(); - - await uow.CompleteAsync(); - } - } - [Fact] public async Task Token_Should_Become_Invalid_After_Email_Confirmation_With_Explicit_Revocation() { @@ -129,49 +56,13 @@ public class AbpEmailConfirmationTokenProvider_Tests : AbpIdentityAspNetCoreTest result.Succeeded.ShouldBeTrue(); // ConfirmEmailAsync does NOT update SecurityStamp, so the hash is not - // automatically invalidated. Callers that require single-use semantics must - // explicitly revoke the stored token hash after a successful confirmation. + // automatically invalidated. Callers must explicitly revoke the hash. john = await UserRepository.GetAsync(TestData.UserJohnId); - var removeResult = await UserManager.RemoveEmailConfirmationTokenAsync(john); - removeResult.Succeeded.ShouldBeTrue(); + (await UserManager.RemoveEmailConfirmationTokenAsync(john)).Succeeded.ShouldBeTrue(); john = await UserRepository.GetAsync(TestData.UserJohnId); - var isValid = await UserManager.VerifyUserTokenAsync( - john, - UserManager.Options.Tokens.EmailConfirmationTokenProvider, - UserManager.ConfirmEmailTokenPurpose, - token); - - isValid.ShouldBeFalse(); - - await uow.CompleteAsync(); - } - } - - [Fact] - public async Task Token_Hash_Should_Persist_Across_UnitOfWork_Boundaries() - { - string token; - - // UoW 1: generate the token; UpdateAsync inside GenerateAsync must persist the hash. - using (var uow = UnitOfWorkManager.Begin(requiresNew: true)) - { - var john = await UserRepository.GetAsync(TestData.UserJohnId); - token = await UserManager.GenerateEmailConfirmationTokenAsync(john); - await uow.CompleteAsync(); - } - - // UoW 2: validate using a fresh DbContext to confirm the hash was written to the DB. - using (var uow = UnitOfWorkManager.Begin(requiresNew: true)) - { - var john = await UserRepository.GetAsync(TestData.UserJohnId); - var isValid = await UserManager.VerifyUserTokenAsync( - john, - UserManager.Options.Tokens.EmailConfirmationTokenProvider, - UserManager.ConfirmEmailTokenPurpose, - token); + (await VerifyTokenAsync(john, token)).ShouldBeFalse(); - isValid.ShouldBeTrue(); await uow.CompleteAsync(); } } diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpPasswordResetTokenProvider_Tests.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpPasswordResetTokenProvider_Tests.cs index d0eec0ca94..870829a975 100644 --- a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpPasswordResetTokenProvider_Tests.cs +++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpPasswordResetTokenProvider_Tests.cs @@ -7,20 +7,23 @@ using Xunit; namespace Volo.Abp.Identity.AspNetCore; -public class AbpPasswordResetTokenProvider_Tests : AbpIdentityAspNetCoreTestBase +public class AbpPasswordResetTokenProvider_Tests : AbpSingleActiveTokenProviderTestBase { - protected IIdentityUserRepository UserRepository { get; } - protected IdentityUserManager UserManager { get; } - protected IdentityTestData TestData { get; } - protected IUnitOfWorkManager UnitOfWorkManager { get; } + protected override Task GenerateTokenAsync(IdentityUser user) + => UserManager.GeneratePasswordResetTokenAsync(user); - public AbpPasswordResetTokenProvider_Tests() - { - UserRepository = GetRequiredService(); - UserManager = GetRequiredService(); - TestData = GetRequiredService(); - UnitOfWorkManager = GetRequiredService(); - } + protected override Task VerifyTokenAsync(IdentityUser user, string token) + => UserManager.VerifyUserTokenAsync( + user, + UserManager.Options.Tokens.PasswordResetTokenProvider, + UserManager.ResetPasswordTokenPurpose, + token); + + protected override string GetProviderName() + => UserManager.Options.Tokens.PasswordResetTokenProvider; + + protected override string GetPurpose() + => UserManager.ResetPasswordTokenPurpose; [Fact] public void AbpPasswordResetTokenProvider_Should_Be_Registered() @@ -40,82 +43,6 @@ public class AbpPasswordResetTokenProvider_Tests : AbpIdentityAspNetCoreTestBase identityOptions.Tokens.PasswordResetTokenProvider.ShouldBe(AbpPasswordResetTokenProvider.ProviderName); } - [Fact] - public async Task Generate_And_Verify_Password_Reset_Token_Should_Succeed() - { - using (var uow = UnitOfWorkManager.Begin()) - { - var john = await UserRepository.GetAsync(TestData.UserJohnId); - - var token = await UserManager.GeneratePasswordResetTokenAsync(john); - token.ShouldNotBeNullOrEmpty(); - - var isValid = await UserManager.VerifyUserTokenAsync( - john, - UserManager.Options.Tokens.PasswordResetTokenProvider, - UserManager.ResetPasswordTokenPurpose, - token); - - isValid.ShouldBeTrue(); - - await uow.CompleteAsync(); - } - } - - [Fact] - public async Task Invalid_Token_Should_Fail_Verification() - { - using (var uow = UnitOfWorkManager.Begin()) - { - var john = await UserRepository.GetAsync(TestData.UserJohnId); - - await UserManager.GeneratePasswordResetTokenAsync(john); - - var isValid = await UserManager.VerifyUserTokenAsync( - john, - UserManager.Options.Tokens.PasswordResetTokenProvider, - UserManager.ResetPasswordTokenPurpose, - "invalid-token-value"); - - isValid.ShouldBeFalse(); - - await uow.CompleteAsync(); - } - } - - [Fact] - public async Task Second_Token_Should_Invalidate_First_Token() - { - using (var uow = UnitOfWorkManager.Begin()) - { - var john = await UserRepository.GetAsync(TestData.UserJohnId); - - var firstToken = await UserManager.GeneratePasswordResetTokenAsync(john); - - john = await UserRepository.GetAsync(TestData.UserJohnId); - var secondToken = await UserManager.GeneratePasswordResetTokenAsync(john); - - john = await UserRepository.GetAsync(TestData.UserJohnId); - - var firstTokenValid = await UserManager.VerifyUserTokenAsync( - john, - UserManager.Options.Tokens.PasswordResetTokenProvider, - UserManager.ResetPasswordTokenPurpose, - firstToken); - - var secondTokenValid = await UserManager.VerifyUserTokenAsync( - john, - UserManager.Options.Tokens.PasswordResetTokenProvider, - UserManager.ResetPasswordTokenPurpose, - secondToken); - - firstTokenValid.ShouldBeFalse(); - secondTokenValid.ShouldBeTrue(); - - await uow.CompleteAsync(); - } - } - [Fact] public async Task Token_Should_Become_Invalid_After_Password_Reset() { @@ -131,42 +58,8 @@ public class AbpPasswordResetTokenProvider_Tests : AbpIdentityAspNetCoreTestBase // SecurityStamp has changed after reset, so the old token must be invalid. john = await UserRepository.GetAsync(TestData.UserJohnId); - var isValid = await UserManager.VerifyUserTokenAsync( - john, - UserManager.Options.Tokens.PasswordResetTokenProvider, - UserManager.ResetPasswordTokenPurpose, - token); - - isValid.ShouldBeFalse(); - - await uow.CompleteAsync(); - } - } - - [Fact] - public async Task Token_Hash_Should_Persist_Across_UnitOfWork_Boundaries() - { - string token; - - // UoW 1: generate the token; UpdateAsync inside GenerateAsync must persist the hash. - using (var uow = UnitOfWorkManager.Begin(requiresNew: true)) - { - var john = await UserRepository.GetAsync(TestData.UserJohnId); - token = await UserManager.GeneratePasswordResetTokenAsync(john); - await uow.CompleteAsync(); - } - - // UoW 2: validate using a fresh DbContext to confirm the hash was written to the DB. - using (var uow = UnitOfWorkManager.Begin(requiresNew: true)) - { - var john = await UserRepository.GetAsync(TestData.UserJohnId); - var isValid = await UserManager.VerifyUserTokenAsync( - john, - UserManager.Options.Tokens.PasswordResetTokenProvider, - UserManager.ResetPasswordTokenPurpose, - token); + (await VerifyTokenAsync(john, token)).ShouldBeFalse(); - isValid.ShouldBeTrue(); await uow.CompleteAsync(); } } diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProviderTestBase.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProviderTestBase.cs new file mode 100644 index 0000000000..37f9aec016 --- /dev/null +++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProviderTestBase.cs @@ -0,0 +1,138 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Shouldly; +using Volo.Abp.Uow; +using Xunit; + +namespace Volo.Abp.Identity.AspNetCore; + +/// +/// Abstract base class that exercises the common behaviour of every +/// subclass. +/// Concrete subclasses inject their provider-specific generate/verify helpers +/// so the same test suite runs against each provider. +/// +public abstract class AbpSingleActiveTokenProviderTestBase : AbpIdentityAspNetCoreTestBase +{ + protected IIdentityUserRepository UserRepository { get; } + protected IdentityUserManager UserManager { get; } + protected IdentityTestData TestData { get; } + protected IUnitOfWorkManager UnitOfWorkManager { get; } + + protected AbpSingleActiveTokenProviderTestBase() + { + UserRepository = GetRequiredService(); + UserManager = GetRequiredService(); + TestData = GetRequiredService(); + UnitOfWorkManager = GetRequiredService(); + } + + /// Generates a token for via the provider under test. + protected abstract Task GenerateTokenAsync(IdentityUser user); + + /// Verifies for via the provider under test. + protected abstract Task VerifyTokenAsync(IdentityUser user, string token); + + /// Returns the provider name used to look up the stored hash. + protected abstract string GetProviderName(); + + /// Returns the token purpose used as the hash key prefix. + protected abstract string GetPurpose(); + + private string GetHashKey() => GetPurpose() + AbpSingleActiveTokenProvider.TokenHashSuffix; + + [Fact] + public async Task Generate_And_Verify_Token_Should_Succeed() + { + using (var uow = UnitOfWorkManager.Begin()) + { + var user = await UserRepository.GetAsync(TestData.UserJohnId); + + var token = await GenerateTokenAsync(user); + token.ShouldNotBeNullOrEmpty(); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await VerifyTokenAsync(user, token)).ShouldBeTrue(); + + await uow.CompleteAsync(); + } + } + + [Fact] + public async Task Invalid_Token_Should_Fail_Verification() + { + using (var uow = UnitOfWorkManager.Begin()) + { + var user = await UserRepository.GetAsync(TestData.UserJohnId); + await GenerateTokenAsync(user); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await VerifyTokenAsync(user, "invalid-token-value")).ShouldBeFalse(); + + await uow.CompleteAsync(); + } + } + + [Fact] + public async Task Second_Token_Should_Invalidate_First_Token() + { + using (var uow = UnitOfWorkManager.Begin()) + { + var user = await UserRepository.GetAsync(TestData.UserJohnId); + var firstToken = await GenerateTokenAsync(user); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + var secondToken = await GenerateTokenAsync(user); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + + (await VerifyTokenAsync(user, firstToken)).ShouldBeFalse(); + (await VerifyTokenAsync(user, secondToken)).ShouldBeTrue(); + + await uow.CompleteAsync(); + } + } + + [Fact] + public async Task Corrupted_Hash_Should_Return_False_Instead_Of_Throwing() + { + using (var uow = UnitOfWorkManager.Begin()) + { + var user = await UserRepository.GetAsync(TestData.UserJohnId); + var token = await GenerateTokenAsync(user); + + // Overwrite with a non-hex string to simulate data corruption. + user = await UserRepository.GetAsync(TestData.UserJohnId); + await UserManager.SetAuthenticationTokenAsync(user, GetProviderName(), GetHashKey(), "not-valid-hex!!!"); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + + // ValidateAsync must catch FormatException internally and return false. + (await VerifyTokenAsync(user, token)).ShouldBeFalse(); + + await uow.CompleteAsync(); + } + } + + [Fact] + public async Task Token_Hash_Should_Persist_Across_UnitOfWork_Boundaries() + { + string token; + + // UoW 1: generate; UpdateAsync inside GenerateAsync must write the hash to the DB. + using (var uow = UnitOfWorkManager.Begin(requiresNew: true)) + { + var user = await UserRepository.GetAsync(TestData.UserJohnId); + token = await GenerateTokenAsync(user); + await uow.CompleteAsync(); + } + + // UoW 2: validate with a fresh DbContext to confirm the hash was persisted. + using (var uow = UnitOfWorkManager.Begin(requiresNew: true)) + { + var user = await UserRepository.GetAsync(TestData.UserJohnId); + (await VerifyTokenAsync(user, token)).ShouldBeTrue(); + await uow.CompleteAsync(); + } + } +}