diff --git a/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/IdentityUserManager_SharedUser_SeparateDatabase_Tests.cs b/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/IdentityUserManager_SharedUser_SeparateDatabase_Tests.cs new file mode 100644 index 0000000000..025274ae7c --- /dev/null +++ b/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/IdentityUserManager_SharedUser_SeparateDatabase_Tests.cs @@ -0,0 +1,7 @@ +using Volo.Abp.Identity.EntityFrameworkCore; + +namespace Volo.Abp.Identity; + +public class IdentityUserManager_SharedUser_SeparateDatabase_Tests : IdentityUserManager_SharedUser_SeparateDatabase_Tests +{ +} diff --git a/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/IdentityUserManager_SharedUser_Tests.cs b/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/IdentityUserManager_SharedUser_Tests.cs new file mode 100644 index 0000000000..fb8ea90878 --- /dev/null +++ b/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/IdentityUserManager_SharedUser_Tests.cs @@ -0,0 +1,5 @@ +namespace Volo.Abp.Identity; + +public class IdentityUserManager_SharedUser_Tests : IdentityUserManager_SharedUser_Tests +{ +} diff --git a/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/IdentityUserManager_Tests.cs b/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/IdentityUserManager_Tests.cs index 1982080cf1..82c133e698 100644 --- a/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/IdentityUserManager_Tests.cs +++ b/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/IdentityUserManager_Tests.cs @@ -491,235 +491,3 @@ public class IdentityUserManager_Tests : AbpIdentityDomainTestBase } } -public class SharedTenantUserSharingStrategy_IdentityUserManager_Tests : AbpIdentityDomainTestBase -{ - private readonly IdentityUserManager _identityUserManager; - private readonly IIdentityUserRepository _identityUserRepository; - private readonly ICurrentTenant _currentTenant; - private readonly IUnitOfWorkManager _unitOfWorkManager; - - public SharedTenantUserSharingStrategy_IdentityUserManager_Tests() - { - _identityUserManager = GetRequiredService(); - _identityUserRepository = GetRequiredService(); - _currentTenant = GetRequiredService(); - _unitOfWorkManager = GetRequiredService(); - } - - protected override void AfterAddApplication(IServiceCollection services) - { - services.Configure(options => - { - options.IsEnabled = true; - options.UserSharingStrategy = TenantUserSharingStrategy.Shared; - }); - } - - [Fact] - public async Task FindSharedUserByEmailAsync_Should_Return_Host_User() - { - var tenantId = Guid.NewGuid(); - var email = "shared-email@abp.io"; - - using (var uow = _unitOfWorkManager.Begin()) - { - await CreateUserAsync(null, "shared-host-email", email); - await CreateUserAsync(tenantId, "shared-tenant-email", email); - await uow.CompleteAsync(); - } - - using (_currentTenant.Change(tenantId)) - { - var user = await _identityUserManager.FindSharedUserByEmailAsync(email); - - user.ShouldNotBeNull(); - user.TenantId.ShouldBeNull(); - user.UserName.ShouldBe("shared-host-email"); - } - } - - [Fact] - public async Task FindSharedUserByNameAsync_Should_Return_Host_User() - { - var tenantId = Guid.NewGuid(); - var userName = "shared-user-name"; - - using (var uow = _unitOfWorkManager.Begin()) - { - await CreateUserAsync(null, userName, "shared-host-name@abp.io"); - await CreateUserAsync(tenantId, userName, "shared-tenant-name@abp.io"); - await uow.CompleteAsync(); - } - - using (_currentTenant.Change(tenantId)) - { - var user = await _identityUserManager.FindSharedUserByNameAsync(userName); - - user.ShouldNotBeNull(); - user.TenantId.ShouldBeNull(); - user.UserName.ShouldBe(userName); - } - } - - [Fact] - public async Task FindSharedUserByLoginAsync_Should_Return_Host_User() - { - var tenantId = Guid.NewGuid(); - const string loginProvider = "github"; - const string providerKey = "shared-login"; - - using (var uow = _unitOfWorkManager.Begin()) - { - await CreateUserAsync(null, "shared-host-login", "shared-host-login@abp.io", user => - { - user.AddLogin(new UserLoginInfo(loginProvider, providerKey, "Shared Login")); - }); - - await CreateUserAsync(tenantId, "shared-tenant-login", "shared-tenant-login@abp.io", user => - { - user.AddLogin(new UserLoginInfo(loginProvider, providerKey, "Shared Login")); - }); - - await uow.CompleteAsync(); - } - - using (_currentTenant.Change(tenantId)) - { - var user = await _identityUserManager.FindSharedUserByLoginAsync(loginProvider, providerKey); - - user.ShouldNotBeNull(); - user.TenantId.ShouldBeNull(); - user.UserName.ShouldBe("shared-host-login"); - } - } - - [Fact] - public async Task FindSharedUserByPasskeyIdAsync_Should_Return_Host_User() - { - var tenantId = Guid.NewGuid(); - var credentialId = new byte[] { 10, 20, 30, 40, 50, 60 }; - - using (var uow = _unitOfWorkManager.Begin()) - { - await CreateUserAsync(null, "shared-host-passkey", "shared-host-passkey@abp.io", user => - { - user.AddPasskey(credentialId, new IdentityPasskeyData()); - }); - await uow.CompleteAsync(); - } - - using (_currentTenant.Change(tenantId)) - { - var user = await _identityUserManager.FindSharedUserByPasskeyIdAsync(credentialId); - - user.ShouldNotBeNull(); - user.TenantId.ShouldBeNull(); - user.UserName.ShouldBe("shared-host-passkey"); - } - } - - [Fact] - public async Task FindSharedUserByIdAsync_Should_Find_Tenant_User_From_Host_Context() - { - var tenantId = Guid.NewGuid(); - IdentityUser tenantUser; - - using (var uow = _unitOfWorkManager.Begin()) - { - tenantUser = await CreateUserAsync(tenantId, "shared-id-tenant-only", "shared-id-tenant-only@abp.io"); - await uow.CompleteAsync(); - } - - // Simulates the 2FA mid-flow on a Shared deployment: CurrentTenant is null - // but the user row only exists under a tenant. FindByIdAsync alone would be - // filtered out by the IMultiTenant filter, so FindSharedUserByIdAsync must - // disable the filter and still return the tenant user. - using (_currentTenant.Change(null)) - { - var user = await _identityUserManager.FindSharedUserByIdAsync(tenantUser.Id.ToString()); - - user.ShouldNotBeNull(); - user.Id.ShouldBe(tenantUser.Id); - user.TenantId.ShouldBe(tenantId); - user.UserName.ShouldBe("shared-id-tenant-only"); - } - } - - [Fact] - public async Task FindSharedUserByIdAsync_Should_Find_Host_User_From_Tenant_Context() - { - var tenantId = Guid.NewGuid(); - IdentityUser hostUser; - - using (var uow = _unitOfWorkManager.Begin()) - { - hostUser = await CreateUserAsync(null, "shared-id-host-only", "shared-id-host-only@abp.io"); - await uow.CompleteAsync(); - } - - using (_currentTenant.Change(tenantId)) - { - var user = await _identityUserManager.FindSharedUserByIdAsync(hostUser.Id.ToString()); - - user.ShouldNotBeNull(); - user.Id.ShouldBe(hostUser.Id); - user.TenantId.ShouldBeNull(); - user.UserName.ShouldBe("shared-id-host-only"); - } - } - - [Fact] - public async Task FindSharedUserByIdAsync_Should_Return_Null_For_Unknown_Id() - { - using (_currentTenant.Change(null)) - { - var user = await _identityUserManager.FindSharedUserByIdAsync(Guid.NewGuid().ToString()); - user.ShouldBeNull(); - } - } - - [Fact] - public async Task Login_Then_TwoFactor_MidFlow_Should_Resolve_Same_Tenant_User_In_Shared_Mode() - { - // Covers the data-access contract behind the 2FA redirect bug: - // 1. login lookup (by user name) must find a tenant user from a host context, - // 2. the 2FA mid-flow lookup (by id) must then return the same tenant user - // from the same host context. Regressing either side re-opens the bug. - var tenantId = Guid.NewGuid(); - - using (var uow = _unitOfWorkManager.Begin()) - { - await CreateUserAsync(tenantId, "shared-2fa-linked", "shared-2fa-linked@abp.io"); - await uow.CompleteAsync(); - } - - using (_currentTenant.Change(null)) - { - var loginUser = await _identityUserManager.FindSharedUserByNameAsync("shared-2fa-linked"); - loginUser.ShouldNotBeNull(); - loginUser.TenantId.ShouldBe(tenantId); - - var twoFactorUser = await _identityUserManager.FindSharedUserByIdAsync(loginUser.Id.ToString()); - twoFactorUser.ShouldNotBeNull(); - twoFactorUser.Id.ShouldBe(loginUser.Id); - twoFactorUser.TenantId.ShouldBe(tenantId); - } - } - - private async Task CreateUserAsync( - Guid? tenantId, - string userName, - string email, - Action? configureUser = null) - { - var user = new IdentityUser(Guid.NewGuid(), userName, email, tenantId); - configureUser?.Invoke(user); - - using (_currentTenant.Change(tenantId)) - { - await _identityUserRepository.InsertAsync(user); - } - - return user; - } -} diff --git a/modules/identity/test/Volo.Abp.Identity.EntityFrameworkCore.Tests/Volo/Abp/Identity/EntityFrameworkCore/AbpIdentitySharedUserSeparateDbEntityFrameworkCoreTestModule.cs b/modules/identity/test/Volo.Abp.Identity.EntityFrameworkCore.Tests/Volo/Abp/Identity/EntityFrameworkCore/AbpIdentitySharedUserSeparateDbEntityFrameworkCoreTestModule.cs new file mode 100644 index 0000000000..640965266a --- /dev/null +++ b/modules/identity/test/Volo.Abp.Identity.EntityFrameworkCore.Tests/Volo/Abp/Identity/EntityFrameworkCore/AbpIdentitySharedUserSeparateDbEntityFrameworkCoreTestModule.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; +using Volo.Abp.Data; +using Volo.Abp.EntityFrameworkCore; +using Volo.Abp.EntityFrameworkCore.Sqlite; +using Volo.Abp.Modularity; +using Volo.Abp.MultiTenancy; +using Volo.Abp.MultiTenancy.ConfigurationStore; +using Volo.Abp.PermissionManagement.EntityFrameworkCore; +using Volo.Abp.Uow; + +namespace Volo.Abp.Identity.EntityFrameworkCore; + +// EF/SQLite equivalent of the MongoDB separate-database test module: each predefined tenant +// has its own keep-alive in-memory SQLite connection. Each test method (and therefore each +// AbpApplication) gets a unique connection-string suffix so the test-data seeder runs into +// fresh databases instead of duplicating into shared cache. +[DependsOn( + typeof(AbpIdentityTestBaseModule), + typeof(AbpPermissionManagementEntityFrameworkCoreModule), + typeof(AbpIdentityEntityFrameworkCoreModule), + typeof(AbpEntityFrameworkCoreSqliteModule))] +public class AbpIdentitySharedUserSeparateDbEntityFrameworkCoreTestModule : AbpModule +{ + public static readonly Guid TenantAId = IdentitySharedUserSeparateDbConstants.TenantAId; + public static readonly Guid TenantBId = IdentitySharedUserSeparateDbConstants.TenantBId; + + // Per-app keep-alive connections so the in-memory SQLite databases survive for the test's + // lifetime (without an open connection, shared-cache in-memory databases are discarded). + // Uses AbpUnitTestSqliteConnection (SemaphoreSlim around CreateCommand) — SQLite isn't + // thread-safe and parallel xUnit collections would otherwise race. Disposed in + // OnApplicationShutdown so connections do not accumulate across tests. + private readonly List _keepAlive = new(); + + public override void PreConfigureServices(ServiceConfigurationContext context) + { + PreConfigure(x => x.BusyTimeout = null); + } + + public override void ConfigureServices(ServiceConfigurationContext context) + { + // Unique-per-app suffix so each test method gets a fresh trio of databases (the seeder + // in AbpIdentityTestBaseModule.OnApplicationInitialization expects to write into empty + // tables, which would fail if test methods reused the same shared-cache database). + var suffix = Guid.NewGuid().ToString("N"); + var hostConnection = $"Data Source=AbpIdentity_SeparateDb_Host_{suffix};Mode=Memory;Cache=Shared"; + var tenantAConnection = $"Data Source=AbpIdentity_SeparateDb_TenantA_{suffix};Mode=Memory;Cache=Shared"; + var tenantBConnection = $"Data Source=AbpIdentity_SeparateDb_TenantB_{suffix};Mode=Memory;Cache=Shared"; + + EnsureDatabase(hostConnection); + EnsureDatabase(tenantAConnection); + EnsureDatabase(tenantBConnection); + + Configure(options => + { + options.ConnectionStrings.Default = hostConnection; + }); + + Configure(options => + { + options.Configure(ctx => + { + ctx.DbContextOptions.UseSqlite(ctx.ConnectionString); + }); + }); + + Configure(options => + { + options.IsEnabled = true; + options.UserSharingStrategy = TenantUserSharingStrategy.Shared; + }); + + Configure(options => + { + options.Tenants = new[] + { + new TenantConfiguration(TenantAId, "tenant-a") + { + ConnectionStrings = new ConnectionStrings + { + { ConnectionStrings.DefaultConnectionStringName, tenantAConnection } + } + }, + new TenantConfiguration(TenantBId, "tenant-b") + { + ConnectionStrings = new ConnectionStrings + { + { ConnectionStrings.DefaultConnectionStringName, tenantBConnection } + } + } + }; + }); + + context.Services.AddAlwaysDisableUnitOfWorkTransaction(); + } + + public override void OnApplicationShutdown(ApplicationShutdownContext context) + { + foreach (var connection in _keepAlive) + { + connection.Dispose(); + } + _keepAlive.Clear(); + } + + private void EnsureDatabase(string connectionString) + { + var keepAlive = new AbpUnitTestSqliteConnection(connectionString); + keepAlive.Open(); + _keepAlive.Add(keepAlive); + + new IdentityDbContext( + new DbContextOptionsBuilder().UseSqlite(connectionString).Options) + .GetService().CreateTables(); + + new PermissionManagementDbContext( + new DbContextOptionsBuilder().UseSqlite(connectionString).Options) + .GetService().CreateTables(); + } +} diff --git a/modules/identity/test/Volo.Abp.Identity.MongoDB.Tests/Volo/Abp/Identity/MongoDB/AbpIdentitySharedUserSeparateDbMongoDbTestModule.cs b/modules/identity/test/Volo.Abp.Identity.MongoDB.Tests/Volo/Abp/Identity/MongoDB/AbpIdentitySharedUserSeparateDbMongoDbTestModule.cs new file mode 100644 index 0000000000..0b11a09896 --- /dev/null +++ b/modules/identity/test/Volo.Abp.Identity.MongoDB.Tests/Volo/Abp/Identity/MongoDB/AbpIdentitySharedUserSeparateDbMongoDbTestModule.cs @@ -0,0 +1,56 @@ +using System; +using Volo.Abp.Data; +using Volo.Abp.Modularity; +using Volo.Abp.MultiTenancy; +using Volo.Abp.MultiTenancy.ConfigurationStore; + +namespace Volo.Abp.Identity.MongoDB; + +// Registers two predefined tenants, each pointing at its own physical MongoDB database. +// Used by SharedUser tests that need true cross-database isolation (the only place where +// AbpIdentityUserValidator's shared-mode owner lookup and FindSharedUserBy*Async deviate from +// single-DB behavior). +[DependsOn(typeof(AbpIdentityMongoDbTestModule))] +public class AbpIdentitySharedUserSeparateDbMongoDbTestModule : AbpModule +{ + public static readonly Guid TenantAId = IdentitySharedUserSeparateDbConstants.TenantAId; + public static readonly Guid TenantBId = IdentitySharedUserSeparateDbConstants.TenantBId; + + public override void ConfigureServices(ServiceConfigurationContext context) + { + // Fixed tenant db names so MongoSandbox's embedded mongod does not accumulate one tenant + // db per test method. The host/default database is still configured by + // AbpIdentityMongoDbTestModule (random per test run), since AbpIdentityTestBaseModule + // re-seeds the host admin/role on every AbpApplication initialization. Each test method + // generates unique email / userName so cross-test data does not collide. + var tenantAConnection = MongoDbFixture.GetConnectionString("AbpIdentity_SharedSeparateDb_TenantA"); + var tenantBConnection = MongoDbFixture.GetConnectionString("AbpIdentity_SharedSeparateDb_TenantB"); + + Configure(options => + { + options.IsEnabled = true; + options.UserSharingStrategy = TenantUserSharingStrategy.Shared; + }); + + Configure(options => + { + options.Tenants = new[] + { + new TenantConfiguration(TenantAId, "tenant-a") + { + ConnectionStrings = new ConnectionStrings + { + { ConnectionStrings.DefaultConnectionStringName, tenantAConnection } + } + }, + new TenantConfiguration(TenantBId, "tenant-b") + { + ConnectionStrings = new ConnectionStrings + { + { ConnectionStrings.DefaultConnectionStringName, tenantBConnection } + } + } + }; + }); + } +} diff --git a/modules/identity/test/Volo.Abp.Identity.MongoDB.Tests/Volo/Abp/Identity/MongoDB/IdentityUserManager_SharedUser_SeparateDatabase_Tests.cs b/modules/identity/test/Volo.Abp.Identity.MongoDB.Tests/Volo/Abp/Identity/MongoDB/IdentityUserManager_SharedUser_SeparateDatabase_Tests.cs new file mode 100644 index 0000000000..54456a2e9d --- /dev/null +++ b/modules/identity/test/Volo.Abp.Identity.MongoDB.Tests/Volo/Abp/Identity/MongoDB/IdentityUserManager_SharedUser_SeparateDatabase_Tests.cs @@ -0,0 +1,8 @@ +using Xunit; + +namespace Volo.Abp.Identity.MongoDB; + +[Collection(MongoTestCollection.Name)] +public class IdentityUserManager_SharedUser_SeparateDatabase_Tests : IdentityUserManager_SharedUser_SeparateDatabase_Tests +{ +} diff --git a/modules/identity/test/Volo.Abp.Identity.MongoDB.Tests/Volo/Abp/Identity/MongoDB/IdentityUserManager_SharedUser_Tests.cs b/modules/identity/test/Volo.Abp.Identity.MongoDB.Tests/Volo/Abp/Identity/MongoDB/IdentityUserManager_SharedUser_Tests.cs new file mode 100644 index 0000000000..c8e7c239a2 --- /dev/null +++ b/modules/identity/test/Volo.Abp.Identity.MongoDB.Tests/Volo/Abp/Identity/MongoDB/IdentityUserManager_SharedUser_Tests.cs @@ -0,0 +1,8 @@ +using Xunit; + +namespace Volo.Abp.Identity.MongoDB; + +[Collection(MongoTestCollection.Name)] +public class IdentityUserManager_SharedUser_Tests : IdentityUserManager_SharedUser_Tests +{ +} diff --git a/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentitySharedUserSeparateDbConstants.cs b/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentitySharedUserSeparateDbConstants.cs new file mode 100644 index 0000000000..a2a86ca83e --- /dev/null +++ b/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentitySharedUserSeparateDbConstants.cs @@ -0,0 +1,12 @@ +using System; + +namespace Volo.Abp.Identity; + +// Single source of truth for the predefined tenant ids used by the shared-user separate-database +// test suite. Concrete EF/Mongo test modules and the abstract test class both reference these +// constants directly so the modules don't have to type-couple back into the test class. +public static class IdentitySharedUserSeparateDbConstants +{ + public static readonly Guid TenantAId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + public static readonly Guid TenantBId = Guid.Parse("22222222-2222-2222-2222-222222222222"); +} diff --git a/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityUserManager_SharedUser_SeparateDatabase_Tests.cs b/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityUserManager_SharedUser_SeparateDatabase_Tests.cs new file mode 100644 index 0000000000..23d4a532b6 --- /dev/null +++ b/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityUserManager_SharedUser_SeparateDatabase_Tests.cs @@ -0,0 +1,201 @@ +using System; +using System.Threading.Tasks; +using Shouldly; +using Volo.Abp.Data; +using Volo.Abp.Modularity; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Uow; +using Xunit; + +namespace Volo.Abp.Identity; + +// Multi-tenant separate-database isolation tests, runnable on any storage backend that lets +// each predefined tenant resolve to its own physical connection. EF (SQLite per-tenant +// keep-alive) and MongoDB (per-tenant database) implementations both work. +// +// NOTE on scope: open-source IdentityUserManager.FindSharedUserBy* assumes a single shared +// database; cross-DB shared-user resolution is the Pro UserSharingManager's responsibility. +// These tests therefore only assert the layer the open-source framework owns: tenant +// connection routing and IMultiTenant filter behavior. +// +// Concrete subclasses must register two predefined tenants (TenantAId / TenantBId from this +// class) each with its own connection string, and enable shared-user mode in the test module. +public abstract class IdentityUserManager_SharedUser_SeparateDatabase_Tests : AbpIdentityTestBase + where TStartupModule : IAbpModule +{ + public static readonly Guid TenantAId = IdentitySharedUserSeparateDbConstants.TenantAId; + public static readonly Guid TenantBId = IdentitySharedUserSeparateDbConstants.TenantBId; + + protected IdentityUserManager IdentityUserManager { get; } + protected IIdentityUserRepository IdentityUserRepository { get; } + protected ICurrentTenant CurrentTenant { get; } + protected IUnitOfWorkManager UnitOfWorkManager { get; } + protected IDataFilter DataFilter { get; } + + protected IdentityUserManager_SharedUser_SeparateDatabase_Tests() + { + IdentityUserManager = GetRequiredService(); + IdentityUserRepository = GetRequiredService(); + CurrentTenant = GetRequiredService(); + UnitOfWorkManager = GetRequiredService(); + DataFilter = GetRequiredService(); + } + + [Fact] + public virtual async Task Tenant_Connection_Should_Not_See_Host_Rows() + { + // Disables IMultiTenant before querying so this test fails if connection routing is + // broken (a tenant context unexpectedly hitting the host db) rather than being masked + // by the data filter. + var probeEmail = $"infra-host-{Guid.NewGuid():N}@abp.io"; + Guid hostUserId; + + using (CurrentTenant.Change(null)) + using (var uow = UnitOfWorkManager.Begin(requiresNew: true)) + { + var hostUser = new IdentityUser(Guid.NewGuid(), $"infra-host-{Guid.NewGuid():N}", probeEmail, null); + await IdentityUserRepository.InsertAsync(hostUser); + hostUserId = hostUser.Id; + await uow.CompleteAsync(); + } + + using (CurrentTenant.Change(TenantAId)) + using (DataFilter.Disable()) + using (var uow = UnitOfWorkManager.Begin(requiresNew: true)) + { + (await IdentityUserRepository.GetListAsync()).ShouldNotContain(u => u.Id == hostUserId); + await uow.CompleteAsync(); + } + + using (CurrentTenant.Change(TenantAId)) + { + (await IdentityUserManager.FindByEmailAsync(probeEmail)).ShouldBeNull(); + } + } + + [Fact] + public virtual async Task Host_Connection_Should_Not_See_Tenant_Rows() + { + var probeEmail = $"infra-tenant-{Guid.NewGuid():N}@abp.io"; + Guid tenantUserId; + + using (CurrentTenant.Change(TenantAId)) + using (var uow = UnitOfWorkManager.Begin(requiresNew: true)) + { + var tenantUser = new IdentityUser(Guid.NewGuid(), $"infra-t-{Guid.NewGuid():N}", probeEmail, TenantAId); + await IdentityUserRepository.InsertAsync(tenantUser); + tenantUserId = tenantUser.Id; + await uow.CompleteAsync(); + } + + using (CurrentTenant.Change(null)) + using (DataFilter.Disable()) + using (var uow = UnitOfWorkManager.Begin(requiresNew: true)) + { + (await IdentityUserRepository.GetListAsync()).ShouldNotContain(u => u.Id == tenantUserId); + await uow.CompleteAsync(); + } + + using (CurrentTenant.Change(null)) + { + (await IdentityUserManager.FindByEmailAsync(probeEmail)).ShouldBeNull(); + } + } + + [Fact] + public virtual async Task TenantA_Connection_Should_Not_See_TenantB_Rows() + { + var probeEmail = $"infra-cross-{Guid.NewGuid():N}@abp.io"; + Guid tenantBUserId; + + using (CurrentTenant.Change(TenantBId)) + using (var uow = UnitOfWorkManager.Begin(requiresNew: true)) + { + var u = new IdentityUser(Guid.NewGuid(), $"b-{Guid.NewGuid():N}", probeEmail, TenantBId); + await IdentityUserRepository.InsertAsync(u); + tenantBUserId = u.Id; + await uow.CompleteAsync(); + } + + using (CurrentTenant.Change(TenantAId)) + using (DataFilter.Disable()) + using (var uow = UnitOfWorkManager.Begin(requiresNew: true)) + { + (await IdentityUserRepository.GetListAsync()).ShouldNotContain(u => u.Id == tenantBUserId); + await uow.CompleteAsync(); + } + + using (CurrentTenant.Change(TenantAId)) + { + (await IdentityUserManager.FindByEmailAsync(probeEmail)).ShouldBeNull(); + } + } + + [Fact] + public virtual async Task Different_Tenants_Should_Allow_Same_Email_With_Their_Own_Rows() + { + var email = $"same-email-{Guid.NewGuid():N}@abp.io"; + var nameA = $"a-{Guid.NewGuid():N}"; + var nameB = $"b-{Guid.NewGuid():N}"; + + using (CurrentTenant.Change(TenantAId)) + using (var uow = UnitOfWorkManager.Begin(requiresNew: true)) + { + await IdentityUserRepository.InsertAsync(new IdentityUser(Guid.NewGuid(), nameA, email, TenantAId)); + await uow.CompleteAsync(); + } + using (CurrentTenant.Change(TenantBId)) + using (var uow = UnitOfWorkManager.Begin(requiresNew: true)) + { + await IdentityUserRepository.InsertAsync(new IdentityUser(Guid.NewGuid(), nameB, email, TenantBId)); + await uow.CompleteAsync(); + } + + using (CurrentTenant.Change(TenantAId)) + { + var userA = await IdentityUserManager.FindByEmailAsync(email); + userA.ShouldNotBeNull(); + userA.UserName.ShouldBe(nameA); + } + using (CurrentTenant.Change(TenantBId)) + { + var userB = await IdentityUserManager.FindByEmailAsync(email); + userB.ShouldNotBeNull(); + userB.UserName.ShouldBe(nameB); + } + } + + [Fact] + public virtual async Task Different_Tenants_Should_Allow_Same_UserName_With_Their_Own_Rows() + { + var userName = $"same-name-{Guid.NewGuid():N}"; + var emailA = $"a-{Guid.NewGuid():N}@abp.io"; + var emailB = $"b-{Guid.NewGuid():N}@abp.io"; + + using (CurrentTenant.Change(TenantAId)) + using (var uow = UnitOfWorkManager.Begin(requiresNew: true)) + { + await IdentityUserRepository.InsertAsync(new IdentityUser(Guid.NewGuid(), userName, emailA, TenantAId)); + await uow.CompleteAsync(); + } + using (CurrentTenant.Change(TenantBId)) + using (var uow = UnitOfWorkManager.Begin(requiresNew: true)) + { + await IdentityUserRepository.InsertAsync(new IdentityUser(Guid.NewGuid(), userName, emailB, TenantBId)); + await uow.CompleteAsync(); + } + + using (CurrentTenant.Change(TenantAId)) + { + var userInTenantA = await IdentityUserManager.FindByNameAsync(userName); + userInTenantA.ShouldNotBeNull(); + userInTenantA.Email.ShouldBe(emailA); + } + using (CurrentTenant.Change(TenantBId)) + { + var userInTenantB = await IdentityUserManager.FindByNameAsync(userName); + userInTenantB.ShouldNotBeNull(); + userInTenantB.Email.ShouldBe(emailB); + } + } +} diff --git a/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityUserManager_SharedUser_Tests.cs b/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityUserManager_SharedUser_Tests.cs new file mode 100644 index 0000000000..4c73f4bd80 --- /dev/null +++ b/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityUserManager_SharedUser_Tests.cs @@ -0,0 +1,254 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Volo.Abp.Modularity; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Uow; +using Xunit; + +namespace Volo.Abp.Identity; + +// Abstract test suite for IdentityUserManager.FindSharedUserBy*Async under +// TenantUserSharingStrategy.Shared. Concrete subclasses in Domain.Tests (EF) and +// MongoDB.Tests pick the storage backend by passing the corresponding TStartupModule. +public abstract class IdentityUserManager_SharedUser_Tests : AbpIdentityTestBase + where TStartupModule : IAbpModule +{ + protected IdentityUserManager IdentityUserManager { get; } + protected IIdentityUserRepository IdentityUserRepository { get; } + protected ICurrentTenant CurrentTenant { get; } + protected IUnitOfWorkManager UnitOfWorkManager { get; } + + protected IdentityUserManager_SharedUser_Tests() + { + IdentityUserManager = GetRequiredService(); + IdentityUserRepository = GetRequiredService(); + CurrentTenant = GetRequiredService(); + UnitOfWorkManager = GetRequiredService(); + } + + protected override void AfterAddApplication(IServiceCollection services) + { + services.Configure(options => + { + options.IsEnabled = true; + options.UserSharingStrategy = TenantUserSharingStrategy.Shared; + }); + } + + [Fact] + public virtual async Task FindSharedUserByEmailAsync_Should_Return_Host_User() + { + var tenantId = Guid.NewGuid(); + var email = $"shared-email-{Guid.NewGuid():N}@abp.io"; + + using (var uow = UnitOfWorkManager.Begin()) + { + await CreateUserAsync(null, $"shared-host-email-{Guid.NewGuid():N}", email); + await CreateUserAsync(tenantId, $"shared-tenant-email-{Guid.NewGuid():N}", email); + await uow.CompleteAsync(); + } + + using (CurrentTenant.Change(tenantId)) + { + var user = await IdentityUserManager.FindSharedUserByEmailAsync(email); + user.ShouldNotBeNull(); + user.TenantId.ShouldBeNull(); + } + } + + [Fact] + public virtual async Task FindSharedUserByEmailAsync_Should_Find_Tenant_User_When_No_Host_User() + { + var tenantId = Guid.NewGuid(); + var email = $"shared-tenant-only-{Guid.NewGuid():N}@abp.io"; + + using (var uow = UnitOfWorkManager.Begin()) + { + await CreateUserAsync(tenantId, $"shared-tenant-only-{Guid.NewGuid():N}", email); + await uow.CompleteAsync(); + } + + using (CurrentTenant.Change(null)) + { + var user = await IdentityUserManager.FindSharedUserByEmailAsync(email); + user.ShouldNotBeNull(); + user.TenantId.ShouldBe(tenantId); + } + } + + [Fact] + public virtual async Task FindSharedUserByEmailAsync_Should_Return_Null_For_Unknown_Email() + { + using (CurrentTenant.Change(null)) + { + var user = await IdentityUserManager.FindSharedUserByEmailAsync($"missing-{Guid.NewGuid():N}@abp.io"); + user.ShouldBeNull(); + } + } + + [Fact] + public virtual async Task FindSharedUserByNameAsync_Should_Return_Host_User() + { + var tenantId = Guid.NewGuid(); + var userName = $"shared-name-{Guid.NewGuid():N}"; + + using (var uow = UnitOfWorkManager.Begin()) + { + await CreateUserAsync(null, userName, $"host-{Guid.NewGuid():N}@abp.io"); + await CreateUserAsync(tenantId, userName, $"tenant-{Guid.NewGuid():N}@abp.io"); + await uow.CompleteAsync(); + } + + using (CurrentTenant.Change(tenantId)) + { + var user = await IdentityUserManager.FindSharedUserByNameAsync(userName); + user.ShouldNotBeNull(); + user.TenantId.ShouldBeNull(); + } + } + + [Fact] + public virtual async Task FindSharedUserByLoginAsync_Should_Return_Host_User() + { + var tenantId = Guid.NewGuid(); + var loginProvider = "github"; + var providerKey = $"shared-login-{Guid.NewGuid():N}"; + + using (var uow = UnitOfWorkManager.Begin()) + { + await CreateUserAsync(null, $"host-login-{Guid.NewGuid():N}", $"host-login-{Guid.NewGuid():N}@abp.io", + u => u.AddLogin(new UserLoginInfo(loginProvider, providerKey, "Shared Login"))); + await CreateUserAsync(tenantId, $"tenant-login-{Guid.NewGuid():N}", $"tenant-login-{Guid.NewGuid():N}@abp.io", + u => u.AddLogin(new UserLoginInfo(loginProvider, providerKey, "Shared Login"))); + await uow.CompleteAsync(); + } + + using (CurrentTenant.Change(tenantId)) + { + var user = await IdentityUserManager.FindSharedUserByLoginAsync(loginProvider, providerKey); + user.ShouldNotBeNull(); + user.TenantId.ShouldBeNull(); + } + } + + [Fact] + public virtual async Task FindSharedUserByPasskeyIdAsync_Should_Return_Host_User() + { + var tenantId = Guid.NewGuid(); + var credentialId = Guid.NewGuid().ToByteArray(); + + using (var uow = UnitOfWorkManager.Begin()) + { + await CreateUserAsync(null, $"shared-host-passkey-{Guid.NewGuid():N}", $"shared-host-passkey-{Guid.NewGuid():N}@abp.io", + u => u.AddPasskey(credentialId, new IdentityPasskeyData())); + await uow.CompleteAsync(); + } + + using (CurrentTenant.Change(tenantId)) + { + var user = await IdentityUserManager.FindSharedUserByPasskeyIdAsync(credentialId); + user.ShouldNotBeNull(); + user.TenantId.ShouldBeNull(); + } + } + + [Fact] + public virtual async Task FindSharedUserByIdAsync_Should_Find_Tenant_User_From_Host_Context() + { + // Core 2FA shared-mode bug condition: a tenant-only user must be reachable by id from + // a host context (CurrentTenant=null). The IMultiTenant filter would otherwise hide it. + var tenantId = Guid.NewGuid(); + IdentityUser tenantUser; + + using (var uow = UnitOfWorkManager.Begin()) + { + tenantUser = await CreateUserAsync(tenantId, $"shared-id-tenant-{Guid.NewGuid():N}", $"shared-id-tenant-{Guid.NewGuid():N}@abp.io"); + await uow.CompleteAsync(); + } + + using (CurrentTenant.Change(null)) + { + var user = await IdentityUserManager.FindSharedUserByIdAsync(tenantUser.Id.ToString()); + user.ShouldNotBeNull(); + user.Id.ShouldBe(tenantUser.Id); + user.TenantId.ShouldBe(tenantId); + } + } + + [Fact] + public virtual async Task FindSharedUserByIdAsync_Should_Find_Host_User_From_Tenant_Context() + { + var tenantId = Guid.NewGuid(); + IdentityUser hostUser; + + using (var uow = UnitOfWorkManager.Begin()) + { + hostUser = await CreateUserAsync(null, $"shared-id-host-{Guid.NewGuid():N}", $"shared-id-host-{Guid.NewGuid():N}@abp.io"); + await uow.CompleteAsync(); + } + + using (CurrentTenant.Change(tenantId)) + { + var user = await IdentityUserManager.FindSharedUserByIdAsync(hostUser.Id.ToString()); + user.ShouldNotBeNull(); + user.TenantId.ShouldBeNull(); + } + } + + [Fact] + public virtual async Task FindSharedUserByIdAsync_Should_Return_Null_For_Unknown_Id() + { + using (CurrentTenant.Change(null)) + { + var user = await IdentityUserManager.FindSharedUserByIdAsync(Guid.NewGuid().ToString()); + user.ShouldBeNull(); + } + } + + [Fact] + public virtual async Task Login_Then_TwoFactor_MidFlow_Should_Resolve_Same_Tenant_User() + { + // End-to-end shape of the 2FA shared-mode regression: a host-context lookup-by-name + // followed by lookup-by-id must return the same tenant row both times. + var tenantId = Guid.NewGuid(); + var userName = $"shared-2fa-{Guid.NewGuid():N}"; + + using (var uow = UnitOfWorkManager.Begin()) + { + await CreateUserAsync(tenantId, userName, $"{userName}@abp.io"); + await uow.CompleteAsync(); + } + + using (CurrentTenant.Change(null)) + { + var loginUser = await IdentityUserManager.FindSharedUserByNameAsync(userName); + loginUser.ShouldNotBeNull(); + loginUser.TenantId.ShouldBe(tenantId); + + var twoFactorUser = await IdentityUserManager.FindSharedUserByIdAsync(loginUser.Id.ToString()); + twoFactorUser.ShouldNotBeNull(); + twoFactorUser.Id.ShouldBe(loginUser.Id); + twoFactorUser.TenantId.ShouldBe(tenantId); + } + } + + protected async Task CreateUserAsync( + Guid? tenantId, + string userName, + string email, + Action configureUser = null) + { + var user = new IdentityUser(Guid.NewGuid(), userName, email, tenantId); + configureUser?.Invoke(user); + + using (CurrentTenant.Change(tenantId)) + { + await IdentityUserRepository.InsertAsync(user); + } + + return user; + } +}