elsa-core/test/unit/Elsa.Tenants.UnitTests/Services/DefaultTenantResolverPipelineInvokerTests.cs
Sipke Schoorstra b09a564812
Fix Multitenancy Support and Normalize Tenant ID Handling (#7217)
* Enable multitenancy support and normalize tenant ID handling.

- Activate multitenancy in `Program.cs`.
- Introduce `NormalizeTenantId` method for consistent tenant ID usage.
- Update tenant-related classes and features to support normalization logic.

* Add ADR for adopting empty string as the default tenant ID

- Standardized the tenant ID for the default tenant to use an empty string (`""`) instead of `null`.
- Documented the rationale and migration considerations in ADR 0007.
- Updated ADR table of contents and graph for new entry.

* Apply suggestion from @sfmskywalker

* Update doc/adr/graph.dot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Normalize spacing and improve readability in `Program.cs`. Fix multitenancy condition formatting.

* Fix ADR numbering and update TOC

* Add ADRs for flowchart execution model, tenant deletion event, merge modes, and default tenant ID

- Introduced ADR 0005: Token-centric flowchart execution model for improved loop and join handling.
- Added ADR 0006: Tenant Deleted event for distinct handling of tenant removal.
- Documented ADR 0007: Explicit merge modes for flowchart joins, improving reliability and configurability.
- Included ADR 0008: Standardization of empty string as the default tenant ID for consistency and clarity.

* Add unit tests for tenant ID normalization and multitenancy pipeline invoker

- Added comprehensive unit tests for tenant ID normalization to ensure consistent handling of null, empty, and valid IDs.
- Introduced tests for the multitenancy pipeline invoker covering various tenant resolution scenarios.
- Updated solution to include new unit testing projects for `Elsa.Tenants` and `Elsa.Common`.

* Update unit tests for `ActivityConstructionResult`

- Refactor test parameterization to verify `HasExceptions` property more explicitly.
- Simplify exception creation logic in helper methods.
- Improve test assertions by combining act and assert phases where applicable.

* Enable configuration-based multitenancy with tenant-specific settings

- Introduced a configuration-based tenant provider to streamline tenant initialization and customization.
- Added tenant ID handling filters to ensure tenant ID is applied and filtered automatically.
- Deprecated the `CommonPersistenceFeature` in favor of modular persistence feature extension.

* Update database indexes to include `TenantId` for multitenancy support

- Added `TenantId` to unique constraints on `Triggers` table across all EFCore providers.
- Adjusted index names to reflect the updated constraints.
- Updated trigger configuration to ensure uniqueness includes `TenantId`.

* Add tenant filtering to `DefaultWorkflowDefinitionStorePopulator`

- Introduced `ITenantAccessor` to support tenant-specific filtering of workflow definitions.
- Updated logic to skip workflows not matching the current tenant.

* Update doc/adr/toc.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Remove `CommonPersistenceFeature` as it has been deprecated

* Add tenant-specific filtering to workflow import logic in `DefaultWorkflowDefinitionStorePopulator`

* Replace hardcoded tenant ID with `Tenant.DefaultTenantId` in integration tests

* Update database indexes and migration logic to support `TenantId` for multitenancy

- Added `TenantId` to unique constraints on the `Triggers` table and updated index names.
- Included logic to drop outdated indexes without `TenantId` during migration.
- Adjusted tests to account for `TenantId` in workflow identity and indexing scenarios.

* Remove `TenantId` from workflow identity construction in concurrent trigger indexing tests

* Introduce `SelectiveMockLockProvider` for precise lock mocking in tests

- Added `SelectiveMockLockProvider` to allow targeted lock mocking without affecting unrelated background operations.
- Updated test services to use `SelectiveMockLockProvider` in place of `TestDistributedLockProvider`.
- Refactored `DistributedLockResilienceTests` to support selective mocking for deterministic and reliable assertions.

* Update Elsa.sln

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Normalize tenant ID handling in `DefaultWorkflowDefinitionStorePopulator` for consistent filtering

* Refactor `TenantResolverResult` to support explicit resolved/unresolved state handling

- Updated `TenantResolverResult` to include an explicit `_isResolved` property.
- Adjusted `ResolveTenantId()` and `IsResolved` logic for improved clarity and robustness.
- Simplified tenant resolution invocation in `TenantResolverBase`.
- Removed redundant normalization in `DefaultTenantResolverPipelineInvoker`.

* Normalize tenant ID handling in `DefaultWorkflowDefinitionStorePopulator` and `ClrWorkflowsProvider`.

* Refactor `DefaultWorkflowDefinitionStorePopulatorTests`: streamline object initializations and add tenant-specific test coverage for `PopulateStoreAsync`.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-30 19:54:13 +01:00

182 lines
6.4 KiB
C#

using Elsa.Common.Multitenancy;
using Elsa.Tenants.Options;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
namespace Elsa.Tenants.UnitTests.Services;
public class DefaultTenantResolverPipelineInvokerTests
{
[Fact]
public async Task InvokePipelineAsync_WithEmptyStringTenantId_FindsDefaultTenant()
{
// Arrange
var tenants = CreateDefaultTenantList();
var (invoker, _) = CreateInvoker(tenants, TenantResolverResult.Resolved(""));
// Act
var result = await invoker.InvokePipelineAsync();
// Assert
Assert.NotNull(result);
Assert.Equal("Default", result.Name);
}
[Fact]
public async Task InvokePipelineAsync_WithValidTenantId_FindsCorrectTenant()
{
// Arrange
var tenants = CreateDefaultTenantList();
var (invoker, _) = CreateInvoker(tenants, TenantResolverResult.Resolved("tenant1"));
// Act
var result = await invoker.InvokePipelineAsync();
// Assert
Assert.NotNull(result);
Assert.Equal("Tenant 1", result.Name);
}
[Fact]
public async Task InvokePipelineAsync_WithNonExistentTenantId_ReturnsNull()
{
// Arrange
var tenants = CreateDefaultTenantList();
var (invoker, _) = CreateInvoker(tenants, TenantResolverResult.Resolved("non-existent"));
// Act
var result = await invoker.InvokePipelineAsync();
// Assert
Assert.Null(result);
}
[Fact]
public async Task InvokePipelineAsync_WithNullTenantIdsInList_DoesNotThrowDictionaryException()
{
// Arrange - Simulates legacy data with null IDs
var tenants = new List<Tenant>
{
new() { Id = null!, Name = "Legacy Null Tenant" },
new() { Id = "tenant1", Name = "Tenant 1" }
};
var (invoker, _) = CreateInvoker(tenants, TenantResolverResult.Unresolved());
// Act & Assert - Should not throw
var result = await invoker.InvokePipelineAsync();
Assert.Null(result);
}
[Fact]
public async Task InvokePipelineAsync_WithUnresolvedResult_ReturnsNull()
{
// Arrange
var tenants = CreateDefaultTenantList();
var (invoker, _) = CreateInvoker(tenants, TenantResolverResult.Unresolved());
// Act
var result = await invoker.InvokePipelineAsync();
// Assert
Assert.Null(result);
}
[Fact]
public async Task InvokePipelineAsync_WithMultipleResolvers_UsesFirstResolvedResult()
{
// Arrange
var tenants = CreateDefaultTenantList();
var mockResolver1 = CreateMockResolver(TenantResolverResult.Resolved("tenant1"));
var mockResolver2 = CreateMockResolver(TenantResolverResult.Resolved("tenant2"));
var invoker = CreateInvokerWithMultipleResolvers(tenants, mockResolver1, mockResolver2);
// Act
var result = await invoker.InvokePipelineAsync();
// Assert
Assert.NotNull(result);
Assert.Equal("tenant1", result.Id);
await mockResolver2.DidNotReceive().ResolveAsync(Arg.Any<TenantResolverContext>());
}
[Fact]
public async Task InvokePipelineAsync_WithLegacyNullTenantIds_NormalizesAndFindsDefaultTenant()
{
// Arrange - Simulates legacy data with null ID that gets normalized
var tenants = new List<Tenant>
{
new() { Id = null!, Name = "Legacy" }, // Will be normalized to ""
new() { Id = "tenant1", Name = "Tenant 1" }
};
var (invoker, _) = CreateInvoker(tenants, TenantResolverResult.Resolved(""));
// Act
var result = await invoker.InvokePipelineAsync();
// Assert - The null tenant ID gets normalized to "" in dictionary, so it should be found
Assert.NotNull(result);
Assert.Equal("Legacy", result.Name); // Should find the Legacy tenant (normalized from null)
}
// Helper methods
private static List<Tenant> CreateDefaultTenantList() => new()
{
new() { Id = Tenant.DefaultTenantId, Name = "Default" },
new() { Id = "tenant1", Name = "Tenant 1" },
new() { Id = "tenant2", Name = "Tenant 2" }
};
private static ITenantResolver CreateMockResolver(TenantResolverResult result)
{
var mockResolver = Substitute.For<ITenantResolver>();
mockResolver.ResolveAsync(Arg.Any<TenantResolverContext>()).Returns(result);
return mockResolver;
}
private static (DefaultTenantResolverPipelineInvoker Invoker, ITenantResolver Resolver) CreateInvoker(
List<Tenant> tenants,
TenantResolverResult resolverResult)
{
var tenantsProvider = Substitute.For<ITenantsProvider>();
tenantsProvider.ListAsync(Arg.Any<CancellationToken>()).Returns(tenants);
var mockResolver = CreateMockResolver(resolverResult);
// Use a mock pipeline builder that directly returns our mock resolver
var pipelineBuilder = Substitute.For<ITenantResolverPipelineBuilder>();
pipelineBuilder.Build(Arg.Any<IServiceProvider>()).Returns(new[] { mockResolver });
var options = Microsoft.Extensions.Options.Options.Create(new MultitenancyOptions
{
TenantResolverPipelineBuilder = pipelineBuilder
});
var serviceProvider = Substitute.For<IServiceProvider>();
var logger = NullLogger<DefaultTenantResolverPipelineInvoker>.Instance;
var invoker = new DefaultTenantResolverPipelineInvoker(options, tenantsProvider, serviceProvider, logger);
return (invoker, mockResolver);
}
private static DefaultTenantResolverPipelineInvoker CreateInvokerWithMultipleResolvers(
List<Tenant> tenants,
params ITenantResolver[] resolvers)
{
var tenantsProvider = Substitute.For<ITenantsProvider>();
tenantsProvider.ListAsync(Arg.Any<CancellationToken>()).Returns(tenants);
// Use a mock pipeline builder that directly returns our mock resolvers
var pipelineBuilder = Substitute.For<ITenantResolverPipelineBuilder>();
pipelineBuilder.Build(Arg.Any<IServiceProvider>()).Returns(resolvers);
var options = Microsoft.Extensions.Options.Options.Create(new MultitenancyOptions
{
TenantResolverPipelineBuilder = pipelineBuilder
});
var serviceProvider = Substitute.For<IServiceProvider>();
var logger = NullLogger<DefaultTenantResolverPipelineInvoker>.Instance;
return new(options, tenantsProvider, serviceProvider, logger);
}
}