diff --git a/src/Idmt.Plugin/Features/Manage/RegisterUser.cs b/src/Idmt.Plugin/Features/Manage/RegisterUser.cs index 5380d1e..2f37490 100644 --- a/src/Idmt.Plugin/Features/Manage/RegisterUser.cs +++ b/src/Idmt.Plugin/Features/Manage/RegisterUser.cs @@ -131,6 +131,11 @@ public async Task> HandleAsync( { return Result.Failure("Insufficient permissions to assign this role.", StatusCodes.Status403Forbidden); } + + // Get the tenant ID from the current user service (from tenant context) + var tenantId = currentUserService.TenantId + ?? throw new InvalidOperationException("Tenant context is not available. Cannot register user without tenant context."); + // Create user entity with basic information, no password set // User is active by default, but email is not confirmed until password is set // When the user is unregistered, we set IsActive to false (soft delete) @@ -140,7 +145,7 @@ public async Task> HandleAsync( Email = request.Email, EmailConfirmed = false, // Will be confirmed when password is set IsActive = true, - TenantId = currentUserService.TenantId!, + TenantId = tenantId, LastLoginAt = null, }; @@ -204,7 +209,7 @@ public async Task> HandleAsync( ? linkGenerator.GeneratePasswordResetApiLink(user.Email, token) : linkGenerator.GeneratePasswordResetFormLink(user.Email, token); - logger.LogInformation("User created: {Email}. Request by {RequestingUserId}. Tenant: {TenantId}.", user.Email, currentUserService.UserId, currentUserService.TenantId); + logger.LogInformation("User created: {Email}. Request by {RequestingUserId}. Tenant: {TenantId}.", user.Email, currentUserService.UserId, tenantId); await emailSender.SendPasswordResetLinkAsync(user, user.Email, passwordSetupUrl); diff --git a/src/Idmt.Plugin/Middleware/ValidateBearerTokenTenantMiddleware.cs b/src/Idmt.Plugin/Middleware/ValidateBearerTokenTenantMiddleware.cs index 12c16a0..68ae323 100644 --- a/src/Idmt.Plugin/Middleware/ValidateBearerTokenTenantMiddleware.cs +++ b/src/Idmt.Plugin/Middleware/ValidateBearerTokenTenantMiddleware.cs @@ -1,9 +1,13 @@ using Finbuckle.MultiTenant.Abstractions; +using Idmt.Plugin.Configuration; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; namespace Idmt.Plugin.Middleware; -public class ValidateBearerTokenTenantMiddleware(IMultiTenantContextAccessor tenantContextAccessor) : IMiddleware +public class ValidateBearerTokenTenantMiddleware( + IMultiTenantContextAccessor tenantContextAccessor, + IOptions idmtOptions) : IMiddleware { public async Task InvokeAsync(HttpContext context, RequestDelegate next) { @@ -39,7 +43,9 @@ private bool ValidateTokenTenant( } // Get the tenant claim type from configuration - string tenantClaimType = "__tenant__"; + var tenantClaimType = idmtOptions.Value.MultiTenant.StrategyOptions.GetValueOrDefault( + IdmtMultiTenantStrategy.ClaimOption, + IdmtMultiTenantStrategy.DefaultClaimType); // Extract tenant claim from token var tokenTenantClaim = context.User.FindFirst(tenantClaimType)?.Value; @@ -51,8 +57,9 @@ private bool ValidateTokenTenant( return false; } - // Validate that the token's tenant matches the current request's tenant - if (!tokenTenantClaim.Equals(currentTenant.Id, StringComparison.Ordinal)) + // Validate that the token's tenant identifier matches the current request's tenant identifier + // The factory adds tenantInfo.Identifier to the claim, so we compare with Identifier, not Id + if (!tokenTenantClaim.Equals(currentTenant.Identifier, StringComparison.Ordinal)) { context.Response.StatusCode = StatusCodes.Status403Forbidden; return false; diff --git a/src/Idmt.Plugin/Services/CurrentUserService.cs b/src/Idmt.Plugin/Services/CurrentUserService.cs index 0edb1f5..040d395 100644 --- a/src/Idmt.Plugin/Services/CurrentUserService.cs +++ b/src/Idmt.Plugin/Services/CurrentUserService.cs @@ -1,10 +1,13 @@ using System.Security.Claims; +using Finbuckle.MultiTenant.Abstractions; using Idmt.Plugin.Configuration; using Microsoft.Extensions.Options; namespace Idmt.Plugin.Services; -internal sealed class CurrentUserService(IOptions idmtOptions) : ICurrentUserService +internal sealed class CurrentUserService( + IOptions idmtOptions, + IMultiTenantContextAccessor multiTenantContextAccessor) : ICurrentUserService { public ClaimsPrincipal? User { get; private set; } @@ -22,8 +25,11 @@ internal sealed class CurrentUserService(IOptions idmtOptions) : IC public string? UserName => User?.FindFirstValue(ClaimTypes.Name); public string? TenantId => - User?.FindFirstValue(idmtOptions.Value.MultiTenant.StrategyOptions.GetValueOrDefault(IdmtMultiTenantStrategy.ClaimOption, - IdmtMultiTenantStrategy.DefaultClaimType)); + multiTenantContextAccessor.MultiTenantContext?.TenantInfo?.Id; + + public string? TenantIdentifier => + User?.FindFirstValue(idmtOptions.Value.MultiTenant.StrategyOptions.GetValueOrDefault(IdmtMultiTenantStrategy.ClaimOption, IdmtMultiTenantStrategy.DefaultClaimType)) ?? + multiTenantContextAccessor.MultiTenantContext?.TenantInfo?.Identifier; public bool IsActive => User?.FindFirstValue("is_active") == "true"; diff --git a/src/Idmt.Plugin/Services/ICurrentUserService.cs b/src/Idmt.Plugin/Services/ICurrentUserService.cs index 459169f..40c57df 100644 --- a/src/Idmt.Plugin/Services/ICurrentUserService.cs +++ b/src/Idmt.Plugin/Services/ICurrentUserService.cs @@ -13,6 +13,7 @@ public interface ICurrentUserService string? Email { get; } string? UserName { get; } string? TenantId { get; } + string? TenantIdentifier { get; } bool IsInRole(string role); diff --git a/src/Idmt.Plugin/Services/IdmtUserClaimsPrincipalFactory.cs b/src/Idmt.Plugin/Services/IdmtUserClaimsPrincipalFactory.cs index e1ff1d8..21d3b64 100644 --- a/src/Idmt.Plugin/Services/IdmtUserClaimsPrincipalFactory.cs +++ b/src/Idmt.Plugin/Services/IdmtUserClaimsPrincipalFactory.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using Finbuckle.MultiTenant.Abstractions; using Idmt.Plugin.Configuration; using Idmt.Plugin.Models; using Microsoft.AspNetCore.Identity; @@ -10,6 +11,7 @@ internal sealed class IdmtUserClaimsPrincipalFactory( UserManager userManager, RoleManager roleManager, IOptions optionsAccessor, + IMultiTenantStore tenantStore, IOptions idmtOptions) : UserClaimsPrincipalFactory(userManager, roleManager, optionsAccessor) { @@ -23,7 +25,10 @@ protected override async Task GenerateClaimsAsync(IdmtUser user) // Add tenant claim for multi-tenant strategies (header, claim, route) // This ensures token validation includes tenant context var claimKey = idmtOptions.Value.MultiTenant.StrategyOptions.GetValueOrDefault(IdmtMultiTenantStrategy.ClaimOption, IdmtMultiTenantStrategy.DefaultClaimType); - identity.AddClaim(new Claim(claimKey, user.TenantId)); + + // Try to get tenant info from store using user's TenantId + var tenantInfo = await tenantStore.GetAsync(user.TenantId) ?? throw new InvalidOperationException($"Tenant information not found for tenant ID: {user.TenantId}. User ID: {user.Id}"); + identity.AddClaim(new Claim(claimKey, tenantInfo.Identifier)); return identity; } diff --git a/src/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs b/src/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs index 22a5461..5ec3e56 100644 --- a/src/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs +++ b/src/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs @@ -98,7 +98,7 @@ public HttpClient CreateClientWithTenant(string? tenantId = null, bool allowAuto } if (_strategies.Contains(IdmtMultiTenantStrategy.Header)) { - client.DefaultRequestHeaders.TryAddWithoutValidation("__tenant__", tenantId); + client.DefaultRequestHeaders.TryAddWithoutValidation(IdmtMultiTenantStrategy.DefaultHeaderName, tenantId); } return client; } diff --git a/src/tests/Idmt.UnitTests/Services/CoreServicesTests.cs b/src/tests/Idmt.UnitTests/Services/CoreServicesTests.cs index 295d384..6d929c6 100644 --- a/src/tests/Idmt.UnitTests/Services/CoreServicesTests.cs +++ b/src/tests/Idmt.UnitTests/Services/CoreServicesTests.cs @@ -20,13 +20,16 @@ namespace Idmt.UnitTests.Services; /// public class CurrentUserServiceTests { - private readonly Mock _tenantAccessorMock; + private readonly Mock> _optionsMock; + private readonly Mock _tenantContextAccessorMock; private readonly CurrentUserService _service; public CurrentUserServiceTests() { - _tenantAccessorMock = new Mock(); - _service = new CurrentUserService(_tenantAccessorMock.Object); + _optionsMock = new Mock>(); + _optionsMock.Setup(x => x.Value).Returns(IdmtOptions.Default); + _tenantContextAccessorMock = new Mock(); + _service = new CurrentUserService(_optionsMock.Object, _tenantContextAccessorMock.Object); } [Fact] @@ -34,15 +37,16 @@ public void UserId_ReturnsCurrentUserId_WhenUserExists() { var userId = Guid.NewGuid(); var user = new System.Security.Claims.ClaimsPrincipal( - new System.Security.Claims.ClaimsIdentity(new[] - { + new System.Security.Claims.ClaimsIdentity( + [ new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, userId.ToString()) - })); + ])); _service.SetCurrentUser(user, "127.0.0.1", "TestAgent/1.0"); var result = _service.UserId; + Assert.NotNull(result); Assert.Equal(userId, result); } @@ -56,6 +60,7 @@ public void UserId_ReturnsEmptyGuid_WhenUserDoesNotExist() var result = _service.UserId; + Assert.NotNull(result); Assert.Equal(Guid.Empty, result); } @@ -63,10 +68,10 @@ public void UserId_ReturnsEmptyGuid_WhenUserDoesNotExist() public void IsInRole_ReturnsTrue_WhenUserHasRole() { var user = new System.Security.Claims.ClaimsPrincipal( - new System.Security.Claims.ClaimsIdentity(new[] - { + new System.Security.Claims.ClaimsIdentity( + [ new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Role, "Admin") - })); + ])); _service.SetCurrentUser(user, "127.0.0.1", "TestAgent/1.0"); @@ -79,10 +84,10 @@ public void IsInRole_ReturnsTrue_WhenUserHasRole() public void IsInRole_ReturnsFalse_WhenUserDoesNotHaveRole() { var user = new System.Security.Claims.ClaimsPrincipal( - new System.Security.Claims.ClaimsIdentity(new[] - { + new System.Security.Claims.ClaimsIdentity( + [ new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Role, "User") - })); + ])); _service.SetCurrentUser(user, "127.0.0.1", "TestAgent/1.0"); @@ -98,6 +103,143 @@ public void IsInRole_ReturnsFalse_WhenUserNotSet() Assert.False(result); } + + [Fact] + public void TenantIdentifier_ReturnsTenantIdentifier_WhenClaimExists() + { + const string tenantId = "tenant-123"; + var user = new System.Security.Claims.ClaimsPrincipal( + new System.Security.Claims.ClaimsIdentity( + [ + new System.Security.Claims.Claim(IdmtMultiTenantStrategy.DefaultClaimType, tenantId) + ])); + + _service.SetCurrentUser(user, "127.0.0.1", "TestAgent/1.0"); + + var result = _service.TenantIdentifier; + + Assert.Equal(tenantId, result); + } + + [Fact] + public void TenantIdentifier_ReturnsNull_WhenClaimDoesNotExist() + { + var user = new System.Security.Claims.ClaimsPrincipal( + new System.Security.Claims.ClaimsIdentity()); + + _service.SetCurrentUser(user, "127.0.0.1", "TestAgent/1.0"); + + var result = _service.TenantIdentifier; + + Assert.Null(result); + } + + [Fact] + public void TenantIdentifier_ReturnsIdentifierFromTenantContext_WhenClaimDoesNotExist() + { + const string tenantIdentifier = "tenant-123"; + var tenantInfo = new IdmtTenantInfo("tenant-id-123", tenantIdentifier, "Test Tenant"); + var tenantContext = new MultiTenantContext(tenantInfo); + + _tenantContextAccessorMock.Setup(x => x.MultiTenantContext).Returns(tenantContext); + + var user = new System.Security.Claims.ClaimsPrincipal( + new System.Security.Claims.ClaimsIdentity()); + + _service.SetCurrentUser(user, "127.0.0.1", "TestAgent/1.0"); + + var result = _service.TenantIdentifier; + + Assert.Equal(tenantIdentifier, result); + } + + [Fact] + public void TenantIdentifier_ReturnsTenantIdentifier_WhenCustomClaimTypeIsConfigured() + { + const string customClaimType = "custom_tenant_claim"; + const string tenantId = "tenant-456"; + + var customOptions = new IdmtOptions + { + MultiTenant = new MultiTenantOptions + { + StrategyOptions = new Dictionary + { + { IdmtMultiTenantStrategy.ClaimOption, customClaimType } + } + } + }; + + var customOptionsMock = new Mock>(); + customOptionsMock.Setup(x => x.Value).Returns(customOptions); + var customTenantContextAccessorMock = new Mock(); + var customService = new CurrentUserService(customOptionsMock.Object, customTenantContextAccessorMock.Object); + + var user = new System.Security.Claims.ClaimsPrincipal( + new System.Security.Claims.ClaimsIdentity( + [ + new System.Security.Claims.Claim(customClaimType, tenantId) + ])); + + customService.SetCurrentUser(user, "127.0.0.1", "TestAgent/1.0"); + + var result = customService.TenantIdentifier; + + Assert.Equal(tenantId, result); + } + + [Fact] + public void IsActive_ReturnsTrue_WhenClaimIsTrue() + { + var user = new System.Security.Claims.ClaimsPrincipal( + new System.Security.Claims.ClaimsIdentity( + [ + new System.Security.Claims.Claim("is_active", "true") + ])); + + _service.SetCurrentUser(user, "127.0.0.1", "TestAgent/1.0"); + + var result = _service.IsActive; + + Assert.True(result); + } + + [Fact] + public void IsActive_ReturnsFalse_WhenClaimIsNotTrue() + { + var user = new System.Security.Claims.ClaimsPrincipal( + new System.Security.Claims.ClaimsIdentity( + [ + new System.Security.Claims.Claim("is_active", "false") + ])); + + _service.SetCurrentUser(user, "127.0.0.1", "TestAgent/1.0"); + + var result = _service.IsActive; + + Assert.False(result); + } + + [Fact] + public void IsActive_ReturnsFalse_WhenClaimDoesNotExist() + { + var user = new System.Security.Claims.ClaimsPrincipal( + new System.Security.Claims.ClaimsIdentity()); + + _service.SetCurrentUser(user, "127.0.0.1", "TestAgent/1.0"); + + var result = _service.IsActive; + + Assert.False(result); + } + + [Fact] + public void IsActive_ReturnsFalse_WhenUserNotSet() + { + var result = _service.IsActive; + + Assert.False(result); + } } /// @@ -228,7 +370,7 @@ public void CanManageUser_ReturnsFalse_WhenSysSupportManagesSysAdmin() { _currentUserServiceMock.Setup(x => x.IsInRole(IdmtDefaultRoleTypes.SysSupport)).Returns(true); - var result = _service.CanManageUser(new[] { IdmtDefaultRoleTypes.SysAdmin }); + var result = _service.CanManageUser([IdmtDefaultRoleTypes.SysAdmin]); Assert.False(result); } @@ -238,7 +380,7 @@ public void CanManageUser_ReturnsTrue_WhenSysSupportManagesTenantAdmin() { _currentUserServiceMock.Setup(x => x.IsInRole(IdmtDefaultRoleTypes.SysSupport)).Returns(true); - var result = _service.CanManageUser(new[] { IdmtDefaultRoleTypes.TenantAdmin }); + var result = _service.CanManageUser([IdmtDefaultRoleTypes.TenantAdmin]); Assert.True(result); } @@ -248,7 +390,7 @@ public void CanManageUser_ReturnsFalse_WhenTenantAdminManagesSysUser() { _currentUserServiceMock.Setup(x => x.IsInRole(IdmtDefaultRoleTypes.TenantAdmin)).Returns(true); - var result = _service.CanManageUser(new[] { IdmtDefaultRoleTypes.SysSupport }); + var result = _service.CanManageUser([IdmtDefaultRoleTypes.SysSupport]); Assert.False(result); } @@ -258,7 +400,7 @@ public void CanManageUser_ReturnsTrue_WhenTenantAdminManagesTenantUser() { _currentUserServiceMock.Setup(x => x.IsInRole(IdmtDefaultRoleTypes.TenantAdmin)).Returns(true); - var result = _service.CanManageUser(new[] { "CustomRole" }); + var result = _service.CanManageUser(["CustomRole"]); Assert.True(result); } diff --git a/src/tests/Idmt.UnitTests/Services/IdmtUserClaimsPrincipalFactoryTests.cs b/src/tests/Idmt.UnitTests/Services/IdmtUserClaimsPrincipalFactoryTests.cs new file mode 100644 index 0000000..8abc9b2 --- /dev/null +++ b/src/tests/Idmt.UnitTests/Services/IdmtUserClaimsPrincipalFactoryTests.cs @@ -0,0 +1,311 @@ +using System.Security.Claims; +using Finbuckle.MultiTenant.Abstractions; +using Idmt.Plugin.Configuration; +using Idmt.Plugin.Models; +using Idmt.Plugin.Services; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using Moq; + +namespace Idmt.UnitTests.Services; + +/// +/// Unit tests for IdmtUserClaimsPrincipalFactory. +/// Tests that custom claims (is_active and tenant) are correctly added to the user's claims identity. +/// +public class IdmtUserClaimsPrincipalFactoryTests +{ + private readonly Mock> _userManagerMock; + private readonly Mock> _roleManagerMock; + private readonly Mock> _identityOptionsMock; + private readonly Mock> _tenantStoreMock; + private readonly Mock> _idmtOptionsMock; + private readonly IdmtUserClaimsPrincipalFactory _factory; + + public IdmtUserClaimsPrincipalFactoryTests() + { + var userStoreMock = Mock.Of>(); + _userManagerMock = new Mock>( + userStoreMock, + null!, null!, null!, null!, null!, null!, null!, null!); + + // Mock UserManager methods that the base class might call + _userManagerMock.Setup(x => x.GetRolesAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + _userManagerMock.Setup(x => x.GetClaimsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + _userManagerMock.Setup(x => x.GetSecurityStampAsync(It.IsAny())) + .ReturnsAsync(() => Guid.NewGuid().ToString()); + _userManagerMock.Setup(x => x.GetUserIdAsync(It.IsAny())) + .ReturnsAsync((IdmtUser u) => u.Id.ToString()); + _userManagerMock.Setup(x => x.GetUserNameAsync(It.IsAny())) + .ReturnsAsync((IdmtUser u) => u.UserName ?? string.Empty); + _userManagerMock.Setup(x => x.GetEmailAsync(It.IsAny())) + .ReturnsAsync((IdmtUser u) => u.Email ?? string.Empty); + _userManagerMock.Setup(x => x.GetPhoneNumberAsync(It.IsAny())) + .ReturnsAsync((IdmtUser u) => u.PhoneNumber ?? string.Empty); + + var roleStoreMock = Mock.Of>(); + _roleManagerMock = new Mock>( + roleStoreMock, + null!, null!, null!, null!); + + _identityOptionsMock = new Mock>(); + var identityOptions = new IdentityOptions + { + ClaimsIdentity = new ClaimsIdentityOptions + { + // Configure claim types to avoid null value issues + EmailClaimType = ClaimTypes.Email, + RoleClaimType = ClaimTypes.Role, + SecurityStampClaimType = "AspNet.Identity.SecurityStamp", + UserIdClaimType = ClaimTypes.NameIdentifier, + UserNameClaimType = ClaimTypes.Name + } + }; + _identityOptionsMock.Setup(x => x.Value).Returns(identityOptions); + + _tenantStoreMock = new Mock>(); + + _idmtOptionsMock = new Mock>(); + _idmtOptionsMock.Setup(x => x.Value).Returns(IdmtOptions.Default); + + _factory = new IdmtUserClaimsPrincipalFactory( + _userManagerMock.Object, + _roleManagerMock.Object, + _identityOptionsMock.Object, + _tenantStoreMock.Object, + _idmtOptionsMock.Object); + } + + private async Task CallGenerateClaimsAsync(IdmtUser user) + { + var method = typeof(IdmtUserClaimsPrincipalFactory) + .GetMethod("GenerateClaimsAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (method == null) + { + throw new InvalidOperationException("GenerateClaimsAsync method not found."); + } + return (ClaimsIdentity)await (dynamic)method.Invoke(_factory, new object[] { user })!; + } + + [Fact] + public async Task CreateAsync_AddsIsActiveClaim_WithCorrectValue() + { + const string tenantId = "tenant-id-123"; + const string tenantIdentifier = "tenant-123"; + var tenantInfo = new IdmtTenantInfo(tenantId, tenantIdentifier, "Test Tenant"); + + var user = new IdmtUser + { + Id = Guid.NewGuid(), + UserName = "testuser", + NormalizedUserName = "TESTUSER", + Email = "test@example.com", + NormalizedEmail = "TEST@EXAMPLE.COM", + EmailConfirmed = true, + PhoneNumber = "1234567890", + PhoneNumberConfirmed = true, + TwoFactorEnabled = false, + LockoutEnabled = false, + AccessFailedCount = 0, + TenantId = tenantId, + IsActive = true, + SecurityStamp = Guid.NewGuid().ToString(), + ConcurrencyStamp = Guid.NewGuid().ToString() + }; + + _tenantStoreMock.Setup(x => x.GetAsync(tenantId)) + .ReturnsAsync(tenantInfo); + + var identity = await CallGenerateClaimsAsync(user); + + var isActiveClaim = identity.FindFirst("is_active"); + Assert.NotNull(isActiveClaim); + Assert.Equal("True", isActiveClaim.Value); + } + + [Fact] + public async Task CreateAsync_AddsIsActiveClaim_WhenUserIsInactive() + { + const string tenantId = "tenant-id-123"; + const string tenantIdentifier = "tenant-123"; + var tenantInfo = new IdmtTenantInfo(tenantId, tenantIdentifier, "Test Tenant"); + + var user = new IdmtUser + { + Id = Guid.NewGuid(), + UserName = "testuser", + Email = "test@example.com", + TenantId = tenantId, + IsActive = false, + SecurityStamp = Guid.NewGuid().ToString(), + ConcurrencyStamp = Guid.NewGuid().ToString() + }; + + _tenantStoreMock.Setup(x => x.GetAsync(tenantId)) + .ReturnsAsync(tenantInfo); + + var identity = await CallGenerateClaimsAsync(user); + + var isActiveClaim = identity.FindFirst("is_active"); + Assert.NotNull(isActiveClaim); + Assert.Equal("False", isActiveClaim.Value); + } + + [Fact] + public async Task CreateAsync_AddsTenantClaim_WithDefaultClaimType() + { + const string tenantId = "tenant-id-456"; + const string tenantIdentifier = "tenant-456"; + var tenantInfo = new IdmtTenantInfo(tenantId, tenantIdentifier, "Test Tenant"); + + var user = new IdmtUser + { + Id = Guid.NewGuid(), + UserName = "testuser", + Email = "test@example.com", + TenantId = tenantId, + IsActive = true, + SecurityStamp = Guid.NewGuid().ToString(), + ConcurrencyStamp = Guid.NewGuid().ToString() + }; + + _tenantStoreMock.Setup(x => x.GetAsync(tenantId)) + .ReturnsAsync(tenantInfo); + + var identity = await CallGenerateClaimsAsync(user); + + var tenantClaim = identity.FindFirst(IdmtMultiTenantStrategy.DefaultClaimType); + Assert.NotNull(tenantClaim); + // The factory adds tenantInfo.Identifier, not tenantId + Assert.Equal(tenantIdentifier, tenantClaim.Value); + } + + [Fact] + public async Task CreateAsync_AddsTenantClaim_WithCustomClaimType() + { + const string customClaimType = "custom_tenant_claim"; + const string tenantId = "tenant-id-789"; + const string tenantIdentifier = "tenant-789"; + var tenantInfo = new IdmtTenantInfo(tenantId, tenantIdentifier, "Test Tenant"); + + var customOptions = new IdmtOptions + { + MultiTenant = new MultiTenantOptions + { + StrategyOptions = new Dictionary + { + { IdmtMultiTenantStrategy.ClaimOption, customClaimType } + } + } + }; + + var customOptionsMock = new Mock>(); + customOptionsMock.Setup(x => x.Value).Returns(customOptions); + + var customTenantStoreMock = new Mock>(); + customTenantStoreMock.Setup(x => x.GetAsync(tenantId)) + .ReturnsAsync(tenantInfo); + + var customFactory = new IdmtUserClaimsPrincipalFactory( + _userManagerMock.Object, + _roleManagerMock.Object, + _identityOptionsMock.Object, + customTenantStoreMock.Object, + customOptionsMock.Object); + + var user = new IdmtUser + { + Id = Guid.NewGuid(), + UserName = "testuser", + Email = "test@example.com", + TenantId = tenantId, + IsActive = true, + SecurityStamp = Guid.NewGuid().ToString(), + ConcurrencyStamp = Guid.NewGuid().ToString() + }; + + var customMethod = typeof(IdmtUserClaimsPrincipalFactory) + .GetMethod("GenerateClaimsAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var identity = (ClaimsIdentity)await (dynamic)customMethod!.Invoke(customFactory, new object[] { user })!; + + var tenantClaim = identity.FindFirst(customClaimType); + Assert.NotNull(tenantClaim); + // The factory adds tenantInfo.Identifier, not tenantId + Assert.Equal(tenantIdentifier, tenantClaim.Value); + + // Verify default claim type is not present + var defaultTenantClaim = identity.FindFirst(IdmtMultiTenantStrategy.DefaultClaimType); + Assert.Null(defaultTenantClaim); + } + + [Fact] + public async Task CreateAsync_IncludesBaseClaims() + { + const string tenantId = "tenant-id-123"; + const string tenantIdentifier = "tenant-123"; + var tenantInfo = new IdmtTenantInfo(tenantId, tenantIdentifier, "Test Tenant"); + + var userId = Guid.NewGuid(); + var user = new IdmtUser + { + Id = userId, + UserName = "testuser", + Email = "test@example.com", + TenantId = tenantId, + IsActive = true, + SecurityStamp = Guid.NewGuid().ToString(), + ConcurrencyStamp = Guid.NewGuid().ToString() + }; + + _tenantStoreMock.Setup(x => x.GetAsync(tenantId)) + .ReturnsAsync(tenantInfo); + + var identity = await CallGenerateClaimsAsync(user); + + // Verify base claims are present (from base.GenerateClaimsAsync) + var nameIdentifierClaim = identity.FindFirst(ClaimTypes.NameIdentifier); + Assert.NotNull(nameIdentifierClaim); + Assert.Equal(userId.ToString(), nameIdentifierClaim.Value); + + var nameClaim = identity.FindFirst(ClaimTypes.Name); + Assert.NotNull(nameClaim); + Assert.Equal(user.UserName, nameClaim.Value); + } + + [Fact] + public async Task CreateAsync_AddsAllCustomClaims() + { + const string tenantId = "tenant-id-999"; + const string tenantIdentifier = "tenant-999"; + var tenantInfo = new IdmtTenantInfo(tenantId, tenantIdentifier, "Test Tenant"); + + var user = new IdmtUser + { + Id = Guid.NewGuid(), + UserName = "testuser", + Email = "test@example.com", + TenantId = tenantId, + IsActive = true, + SecurityStamp = Guid.NewGuid().ToString(), + ConcurrencyStamp = Guid.NewGuid().ToString() + }; + + _tenantStoreMock.Setup(x => x.GetAsync(tenantId)) + .ReturnsAsync(tenantInfo); + + var identity = await CallGenerateClaimsAsync(user); + + // Verify both custom claims are present + var isActiveClaim = identity.FindFirst("is_active"); + Assert.NotNull(isActiveClaim); + Assert.Equal("True", isActiveClaim.Value); + + var tenantClaim = identity.FindFirst(IdmtMultiTenantStrategy.DefaultClaimType); + Assert.NotNull(tenantClaim); + // The factory adds tenantInfo.Identifier, not tenantId + Assert.Equal(tenantIdentifier, tenantClaim.Value); + } +} +