From c0e37e38d08014ee8aaaf27aaabcc11f262638fb Mon Sep 17 00:00:00 2001 From: RalfvandenBurg Date: Sat, 11 Apr 2026 11:00:12 +0200 Subject: [PATCH] Fix/startuptask activate default tenant when empty (#7305) * fix: activate Tenant.Default when no tenants configured (Option C) Ensures IStartupTask implementations (e.g., PopulateRegistriesStartupTask, RunMigrationsStartupTask) run when multitenancy is enabled but the tenant provider returns an empty list. - In DefaultTenantService: treat empty provider response as [Tenant.Default] in GetTenantsDictionaryAsync (initial load) and RefreshAsync - Logic is internal to tenant service; no explicit call required Co-authored-by: Cursor * test: add DefaultTenantService tests for empty-provider fallback - ActivateTenantsAsync_WhenProviderReturnsEmpty_ActivatesDefaultTenant - ListAsync_WhenProviderReturnsEmpty_ReturnsDefaultTenant - ActivateTenantsAsync_WhenProviderReturnsTenants_ReturnsThoseTenants - RefreshAsync_WhenProviderChangesFromTenantsToEmpty_KeepsDefaultTenant Co-authored-by: Cursor * PR feedback disposes serviceprovider also --------- Co-authored-by: Ralf Co-authored-by: Cursor --- .../Multitenancy/Contracts/ITenantService.cs | 1 + .../Implementations/DefaultTenantService.cs | 9 +- .../Multitenancy/DefaultTenantServiceTests.cs | 145 ++++++++++++++++++ 3 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 test/unit/Elsa.Common.UnitTests/Multitenancy/DefaultTenantServiceTests.cs diff --git a/src/modules/Elsa.Common/Multitenancy/Contracts/ITenantService.cs b/src/modules/Elsa.Common/Multitenancy/Contracts/ITenantService.cs index 88b1e44fb..7abc3f042 100644 --- a/src/modules/Elsa.Common/Multitenancy/Contracts/ITenantService.cs +++ b/src/modules/Elsa.Common/Multitenancy/Contracts/ITenantService.cs @@ -63,6 +63,7 @@ public interface ITenantService /// /// Invokes the and caches the result. /// When new tenants are added, lifecycle events are triggered to ensure background tasks are updated. + /// When the provider returns an empty list, is activated so that startup tasks run. /// Task RefreshAsync(CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/modules/Elsa.Common/Multitenancy/Implementations/DefaultTenantService.cs b/src/modules/Elsa.Common/Multitenancy/Implementations/DefaultTenantService.cs index 6a1f9cb98..5dc7f0c05 100644 --- a/src/modules/Elsa.Common/Multitenancy/Implementations/DefaultTenantService.cs +++ b/src/modules/Elsa.Common/Multitenancy/Implementations/DefaultTenantService.cs @@ -77,7 +77,10 @@ public class DefaultTenantService(IServiceScopeFactory scopeFactory, ITenantScop var tenantsProvider = scope.ServiceProvider.GetRequiredService(); var currentTenants = await GetTenantsDictionaryAsync(cancellationToken); var currentTenantIds = currentTenants.Keys; - var newTenants = (await tenantsProvider.ListAsync(cancellationToken)).ToDictionary(x => x.Id.EmptyIfNull()); + var tenantsFromProvider = (await tenantsProvider.ListAsync(cancellationToken)).ToList(); + var newTenants = tenantsFromProvider.Count == 0 + ? new Dictionary { [Tenant.DefaultTenantId] = Tenant.Default } + : tenantsFromProvider.ToDictionary(x => x.Id.EmptyIfNull()); var newTenantIds = newTenants.Keys; var removedTenantIds = currentTenantIds.Except(newTenantIds).ToArray(); var addedTenantIds = newTenantIds.Except(currentTenantIds).ToArray(); @@ -112,7 +115,9 @@ public class DefaultTenantService(IServiceScopeFactory scopeFactory, ITenantScop _tenantsDictionary = new Dictionary(); _tenantScopesDictionary = new Dictionary(); var tenantsProvider = _serviceScope.ServiceProvider.GetRequiredService(); - var tenants = await tenantsProvider.ListAsync(cancellationToken); + var tenants = (await tenantsProvider.ListAsync(cancellationToken)).ToList(); + if (tenants.Count == 0) + tenants = [Tenant.Default]; foreach (var tenant in tenants) await RegisterTenantAsync(tenant, cancellationToken); diff --git a/test/unit/Elsa.Common.UnitTests/Multitenancy/DefaultTenantServiceTests.cs b/test/unit/Elsa.Common.UnitTests/Multitenancy/DefaultTenantServiceTests.cs new file mode 100644 index 000000000..89685a0d5 --- /dev/null +++ b/test/unit/Elsa.Common.UnitTests/Multitenancy/DefaultTenantServiceTests.cs @@ -0,0 +1,145 @@ +using Elsa.Common.Multitenancy; +using Elsa.Common.Multitenancy.EventHandlers; +using Elsa.Common.RecurringTasks; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; + +namespace Elsa.Common.UnitTests.Multitenancy; + +/// +/// Tests for , including the fallback to activate +/// when the tenant provider returns an empty list. +/// +public class DefaultTenantServiceTests +{ + [Fact] + public async Task ActivateTenantsAsync_WhenProviderReturnsEmpty_ActivatesDefaultTenant() + { + // Arrange - provider returns no tenants + var (tenantService, serviceProvider) = await CreateTenantServiceAsync(Array.Empty()); + + try + { + // Act + await tenantService.ActivateTenantsAsync(); + + // Assert + var tenants = (await tenantService.ListAsync()).ToList(); + Assert.Single(tenants); + Assert.Same(Tenant.Default, tenants[0]); + Assert.Equal(Tenant.DefaultTenantId, tenants[0].Id); + } + finally + { + if (tenantService is IAsyncDisposable disposable) + await disposable.DisposeAsync(); + await serviceProvider.DisposeAsync(); + } + } + + [Fact] + public async Task ListAsync_WhenProviderReturnsEmpty_ReturnsDefaultTenant() + { + // Arrange - ListAsync triggers initialization when provider returns empty + var (tenantService, serviceProvider) = await CreateTenantServiceAsync(Array.Empty()); + + try + { + // Act + var tenants = (await tenantService.ListAsync()).ToList(); + + // Assert + Assert.Single(tenants); + Assert.Same(Tenant.Default, tenants[0]); + } + finally + { + if (tenantService is IAsyncDisposable disposable) + await disposable.DisposeAsync(); + await serviceProvider.DisposeAsync(); + } + } + + [Fact] + public async Task ActivateTenantsAsync_WhenProviderReturnsTenants_ReturnsThoseTenants() + { + // Arrange - provider returns specific tenants + var tenant1 = new Tenant { Id = "tenant-1", Name = "Tenant 1" }; + var tenant2 = new Tenant { Id = "tenant-2", Name = "Tenant 2" }; + var (tenantService, serviceProvider) = await CreateTenantServiceAsync([tenant1, tenant2]); + + try + { + // Act + await tenantService.ActivateTenantsAsync(); + + // Assert - should not use Tenant.Default fallback + var tenants = (await tenantService.ListAsync()).ToList(); + Assert.Equal(2, tenants.Count); + Assert.Contains(tenants, t => t.Id == "tenant-1"); + Assert.Contains(tenants, t => t.Id == "tenant-2"); + } + finally + { + if (tenantService is IAsyncDisposable disposable) + await disposable.DisposeAsync(); + await serviceProvider.DisposeAsync(); + } + } + + [Fact] + public async Task RefreshAsync_WhenProviderChangesFromTenantsToEmpty_KeepsDefaultTenant() + { + // Arrange - start with tenants, then provider returns empty (simulating config change) + var tenant1 = new Tenant { Id = "tenant-1", Name = "Tenant 1" }; + var providerReturns = new List { tenant1 }; + var (tenantService, serviceProvider) = await CreateTenantServiceAsync(providerReturns, () => providerReturns); + + try + { + await tenantService.ActivateTenantsAsync(); + Assert.Single(await tenantService.ListAsync()); + + // Simulate provider now returning empty (e.g., config removed all tenants) + providerReturns.Clear(); + + // Act + await tenantService.RefreshAsync(); + + // Assert - should fall back to Tenant.Default instead of having zero tenants + var tenants = (await tenantService.ListAsync()).ToList(); + Assert.Single(tenants); + Assert.Same(Tenant.Default, tenants[0]); + } + finally + { + if (tenantService is IAsyncDisposable disposable) + await disposable.DisposeAsync(); + await serviceProvider.DisposeAsync(); + } + } + + private static Task<(ITenantService TenantService, ServiceProvider ServiceProvider)> CreateTenantServiceAsync(IEnumerable tenants, Func>? tenantsFactory = null) + { + var tenantList = tenants.ToList(); + var getTenants = tenantsFactory ?? (() => tenantList); + + var tenantsProvider = Substitute.For(); + tenantsProvider.ListAsync(Arg.Any()).Returns(_ => getTenants()); + + var services = new ServiceCollection(); + services.AddSingleton(_ => tenantsProvider); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddLogging(); + + var serviceProvider = services.BuildServiceProvider(); + return Task.FromResult((serviceProvider.GetRequiredService(), serviceProvider)); + } +}