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 <cursoragent@cursor.com>

* test: add DefaultTenantService tests for empty-provider fallback

- ActivateTenantsAsync_WhenProviderReturnsEmpty_ActivatesDefaultTenant
- ListAsync_WhenProviderReturnsEmpty_ReturnsDefaultTenant
- ActivateTenantsAsync_WhenProviderReturnsTenants_ReturnsThoseTenants
- RefreshAsync_WhenProviderChangesFromTenantsToEmpty_KeepsDefaultTenant

Co-authored-by: Cursor <cursoragent@cursor.com>

* PR feedback disposes serviceprovider also

---------

Co-authored-by: Ralf <Ralf@Careconnections.nl>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
RalfvandenBurg 2026-04-11 11:00:12 +02:00 committed by GitHub
parent 6bc0cf23ff
commit c0e37e38d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 153 additions and 2 deletions

View file

@ -63,6 +63,7 @@ public interface ITenantService
/// <summary>
/// Invokes the <see cref="ITenantsProvider"/> 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, <see cref="Tenant.Default"/> is activated so that startup tasks run.
/// </summary>
Task RefreshAsync(CancellationToken cancellationToken = default);
}

View file

@ -77,7 +77,10 @@ public class DefaultTenantService(IServiceScopeFactory scopeFactory, ITenantScop
var tenantsProvider = scope.ServiceProvider.GetRequiredService<ITenantsProvider>();
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<string, Tenant> { [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<string, Tenant>();
_tenantScopesDictionary = new Dictionary<Tenant, TenantScope>();
var tenantsProvider = _serviceScope.ServiceProvider.GetRequiredService<ITenantsProvider>();
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);

View file

@ -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;
/// <summary>
/// Tests for <see cref="DefaultTenantService"/>, including the fallback to activate
/// <see cref="Tenant.Default"/> when the tenant provider returns an empty list.
/// </summary>
public class DefaultTenantServiceTests
{
[Fact]
public async Task ActivateTenantsAsync_WhenProviderReturnsEmpty_ActivatesDefaultTenant()
{
// Arrange - provider returns no tenants
var (tenantService, serviceProvider) = await CreateTenantServiceAsync(Array.Empty<Tenant>());
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<Tenant>());
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<Tenant> { 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<Tenant> tenants, Func<List<Tenant>>? tenantsFactory = null)
{
var tenantList = tenants.ToList();
var getTenants = tenantsFactory ?? (() => tenantList);
var tenantsProvider = Substitute.For<ITenantsProvider>();
tenantsProvider.ListAsync(Arg.Any<CancellationToken>()).Returns(_ => getTenants());
var services = new ServiceCollection();
services.AddSingleton(_ => tenantsProvider);
services.AddSingleton<ITenantScopeFactory, DefaultTenantScopeFactory>();
services.AddSingleton<ITenantAccessor, DefaultTenantAccessor>();
services.AddSingleton<ITenantActivatedEvent>(Substitute.For<ITenantActivatedEvent>());
services.AddSingleton<ITenantDeactivatedEvent>(Substitute.For<ITenantDeactivatedEvent>());
services.AddSingleton<ITenantDeletedEvent>(Substitute.For<ITenantDeletedEvent>());
services.AddSingleton<TenantEventsManager>();
services.AddSingleton<RecurringTaskScheduleManager>();
services.AddSingleton<ITenantService, DefaultTenantService>();
services.AddLogging();
var serviceProvider = services.BuildServiceProvider();
return Task.FromResult((serviceProvider.GetRequiredService<ITenantService>(), serviceProvider));
}
}