* 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>
182 lines
6.4 KiB
C#
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);
|
|
}
|
|
}
|