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>
This commit is contained in:
Sipke Schoorstra 2026-01-30 19:54:13 +01:00 committed by GitHub
parent 72569702fe
commit b09a564812
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 1103 additions and 149 deletions

View file

@ -196,12 +196,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "adr", "adr", "{0A04B1FD-06C
doc\adr\0001-record-architecture-decisions.md = doc\adr\0001-record-architecture-decisions.md
doc\adr\0002-fault-propagation-from-child-to-parent-activities.md = doc\adr\0002-fault-propagation-from-child-to-parent-activities.md
doc\adr\0003-direct-bookmark-management-in-workflowexecutioncontext.md = doc\adr\0003-direct-bookmark-management-in-workflowexecutioncontext.md
doc\adr\0004-token-centric-flowchart-execution-model.md = doc\adr\0004-token-centric-flowchart-execution-model.md
doc\adr\graph.dot = doc\adr\graph.dot
doc\adr\toc.md = doc\adr\toc.md
doc\adr\0005-activity-execution-snapshots.md = doc\adr\0004-activity-execution-snapshots.md
doc\adr\0006-tenant-deleted-event.md = doc\adr\0005-tenant-deleted-event.md
doc\adr\0006-adoption-of-explicit-merge-modes-for-flowchart-joins.md = doc\adr\0006-adoption-of-explicit-merge-modes-for-flowchart-joins.md
doc\adr\0005-tenant-deleted-event.md = doc\adr\0005-tenant-deleted-event.md
doc\adr\0005-token-centric-flowchart-execution-model.md = doc\adr\0005-token-centric-flowchart-execution-model.md
doc\adr\0006-tenant-deleted-event.md = doc\adr\0006-tenant-deleted-event.md
doc\adr\0007-adoption-of-explicit-merge-modes-for-flowchart-joins.md = doc\adr\0007-adoption-of-explicit-merge-modes-for-flowchart-joins.md
doc\adr\0008-empty-string-as-default-tenant-id.md = doc\adr\0008-empty-string-as-default-tenant-id.md
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "bounty", "bounty", "{9B80A705-2E31-4012-964A-83963DCDB384}"
@ -327,6 +329,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Resilience.Core.UnitTe
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Common.UnitTests", "test\unit\Elsa.Common.UnitTests\Elsa.Common.UnitTests.csproj", "{A3C07D5B-2A30-494E-B9BC-4B1594B31ABC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Tenants.UnitTests", "test\unit\Elsa.Tenants.UnitTests\Elsa.Tenants.UnitTests.csproj", "{DC476900-D836-4920-A696-CF8796668723}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -591,6 +595,10 @@ Global
{A3C07D5B-2A30-494E-B9BC-4B1594B31ABC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A3C07D5B-2A30-494E-B9BC-4B1594B31ABC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A3C07D5B-2A30-494E-B9BC-4B1594B31ABC}.Release|Any CPU.Build.0 = Release|Any CPU
{DC476900-D836-4920-A696-CF8796668723}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DC476900-D836-4920-A696-CF8796668723}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DC476900-D836-4920-A696-CF8796668723}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DC476900-D836-4920-A696-CF8796668723}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -694,6 +702,7 @@ Global
{874F5A44-DB06-47AB-A18C-2D13942E0147} = {477C2416-312D-46AE-BCD6-8FA1FAB43624}
{B8006D70-1630-43DB-A043-FA89FAC70F37} = {18453B51-25EB-4317-A4B3-B10518252E92}
{A3C07D5B-2A30-494E-B9BC-4B1594B31ABC} = {18453B51-25EB-4317-A4B3-B10518252E92}
{DC476900-D836-4920-A696-CF8796668723} = {18453B51-25EB-4317-A4B3-B10518252E92}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E}

View file

@ -1,4 +1,4 @@
# 4. Token-Centric Flowchart Execution Model
# 5. Token-Centric Flowchart Execution Model
Date: 2025-05-06

View file

@ -1,4 +1,4 @@
# 5. Tenant Deleted Event
# 6. Tenant Deleted Event
Date: 2025-08-05

View file

@ -1,4 +1,4 @@
# 6. Adoption of Explicit Merge Modes for Flowchart Joins
# 7. Adoption of Explicit Merge Modes for Flowchart Joins
Date: 2025-09-30

View file

@ -0,0 +1,48 @@
# 8. Empty String as Default Tenant ID
Date: 2026-01-27
## Status
Accepted
## Context
The multitenancy system in Elsa supports an optional mode where, when multitenancy is disabled, the system assumes a single tenant. When enabled, there's still a default tenant involved. The convention has been to use `null` as the tenant ID for the default tenant.
However, this convention created several issues:
1. **Dictionary compatibility**: The `DefaultTenantResolverPipelineInvoker` attempts to build a dictionary of tenants by their ID using `ToDictionary(x => x.Id)`, which throws an exception because dictionaries do not support null keys.
2. **Inconsistency**: The codebase used `null`, empty string (`""`), and string literal `"default"` interchangeably to refer to the default tenant across different parts of the system (e.g., in configuration files and database records).
3. **Code clarity**: Using `null` as a sentinel value for "default" is implicit and can be unclear to developers reading the code.
## Decision
We will standardize on using an **empty string** (`""`) as the tenant ID for the default tenant instead of `null`. This decision includes:
1. **Define a constant**: Add `Tenant.DefaultTenantId = ""` to explicitly document the convention.
2. **Update Tenant.Default**: Change `Tenant.Default.Id` from `null!` to use the `DefaultTenantId` constant.
3. **Add normalization helper**: Create a `NormalizeTenantId()` extension method that converts `null` to empty string, ensuring backwards compatibility with code that still uses null.
4. **Apply normalization consistently**: Use the normalization method in:
- Dictionary creation in `DefaultTenantResolverPipelineInvoker`
- Tenant lookups in `TenantResolverContext`
- Any other places where tenant IDs are compared or used as dictionary keys
## Consequences
### Positive
- **No more exceptions**: Empty string is a valid dictionary key, eliminating the runtime exception in `DefaultTenantResolverPipelineInvoker`.
- **Backwards compatible**: The `NormalizeTenantId()` extension method ensures that existing code using `null` or empty string will work correctly.
- **Explicit convention**: The `DefaultTenantId` constant makes the convention clear and self-documenting.
- **Simplified logic**: Reduces the need for null-checking throughout the multitenancy code.
- **Consistency**: Aligns with parts of the codebase that were already using empty string (e.g., in configuration files).
### Negative
- **Migration consideration**: Existing data stores that have `null` tenant IDs will need to be normalized to empty strings, though the normalization helper provides a runtime solution.
- **String vs null semantics**: Some developers may find using empty string less intuitive than null for representing "no tenant", though this is mitigated by the explicit constant.
### Neutral
- The empty string convention is common in multitenancy systems and aligns with string-based identifier patterns used elsewhere in the codebase.

View file

@ -8,7 +8,13 @@ _3 [label="3. Direct Bookmark Management in WorkflowExecutionContext"; URL="0003
_2 -> _3 [style="dotted", weight=1];
_4 [label="4. Activity Execution Snapshots"; URL="0004-activity-execution-snapshots.html"];
_3 -> _4 [style="dotted", weight=1];
_5 [label="5. Tenant Deleted Event"; URL="0005-tenant-deleted-event.html"];
_5 [label="5. Token-Centric Flowchart Execution Model"; URL="0005-token-centric-flowchart-execution-model.html"];
_4 -> _5 [style="dotted", weight=1];
_6 [label="6. Tenant Deleted Event"; URL="0006-tenant-deleted-event.html"];
_5 -> _6 [style="dotted", weight=1];
_7 [label="7. Adoption of Explicit Merge Modes for Flowchart Joins"; URL="0007-adoption-of-explicit-merge-modes-for-flowchart-joins.html"];
_6 -> _7 [style="dotted", weight=1];
_8 [label="8. Empty String as Default Tenant ID"; URL="0008-empty-string-as-default-tenant-id.html"];
_7 -> _8 [style="dotted", weight=1];
}
}

View file

@ -4,4 +4,7 @@
* [2. Fault Propagation from Child to Parent Activities](0002-fault-propagation-from-child-to-parent-activities.md)
* [3. Direct Bookmark Management in WorkflowExecutionContext](0003-direct-bookmark-management-in-workflowexecutioncontext.md)
* [4. Activity Execution Snapshots](0004-activity-execution-snapshots.md)
* [5. Tenant Deleted Event](0005-tenant-deleted-event.md)
* [5. Token-Centric Flowchart Execution Model](0005-token-centric-flowchart-execution-model.md)
* [6. Tenant Deleted Event](0006-tenant-deleted-event.md)
* [7. Adoption of Explicit Merge Modes for Flowchart Joins](0007-adoption-of-explicit-merge-modes-for-flowchart-joins.md)
* [8. Empty String as Default Tenant ID](0008-empty-string-as-default-tenant-id.md)

View file

@ -4,12 +4,14 @@ using Elsa.Common.RecurringTasks;
using Elsa.Expressions.Helpers;
using Elsa.Extensions;
using Elsa.Features.Services;
using Elsa.Identity.Multitenancy;
using Elsa.Persistence.EFCore.Extensions;
using Elsa.Persistence.EFCore.Modules.Management;
using Elsa.Persistence.EFCore.Modules.Runtime;
using Elsa.Server.Web.Activities;
using Elsa.Server.Web.ActivityHosts;
using Elsa.Server.Web.Filters;
using Elsa.Tenants;
using Elsa.Tenants.AspNetCore;
using Elsa.Tenants.Extensions;
using Elsa.WorkflowProviders.BlobStorage.ElsaScript.Extensions;
@ -29,7 +31,7 @@ using Microsoft.Extensions.Options;
// ReSharper disable RedundantAssignment
const bool useReadOnlyMode = false;
const bool useSignalR = false; // Disabled until Elsa Studio sends authenticated requests.
const bool useMultitenancy = false;
const bool useMultitenancy = true;
const bool disableVariableWrappers = false;
ObjectConverter.StrictMode = true;
@ -118,6 +120,17 @@ services
http.ConfigureHttpOptions = options => configuration.GetSection("Http").Bind(options);
http.UseCache();
});
if(useMultitenancy)
{
elsa.UseTenants(tenants =>
{
tenants.UseConfigurationBasedTenantsProvider(options => configuration.GetSection("Multitenancy").Bind(options));
tenants.ConfigureMultitenancy(options => options.TenantResolverPipelineBuilder = new TenantResolverPipelineBuilder()
.Append<CurrentUserTenantResolver>());
});
}
ConfigureForTest?.Invoke(elsa);
});

View file

@ -39,6 +39,19 @@
"Sqlite": "Data Source=App_Data/elsa.sqlite.db;Cache=Shared;"
}
}
},
{
"Id": "tenant-2",
"Name": "Tenant 2",
"Configuration": {
"Http": {
"Prefix": "tenant-2",
"Host": "localhost:5001"
},
"ConnectionStrings": {
"Sqlite": "Data Source=App_Data/elsa.sqlite.db;Cache=Shared;"
}
}
}
]
},

View file

@ -29,13 +29,13 @@ public abstract class TenantResolverBase : ITenantResolver
/// <summary>
/// Creates a new instance of <see cref="TenantResolverResult"/> representing a resolved tenant.
/// </summary>
protected TenantResolverResult Resolved(string tenantId) => new(tenantId);
protected TenantResolverResult Resolved(string? tenantId) => TenantResolverResult.Resolved(tenantId);
/// <summary>
/// Creates a new instance of <see cref="TenantResolverResult"/> representing an unresolved tenant.
/// </summary>
protected TenantResolverResult Unresolved() => new(null);
protected TenantResolverResult Unresolved() => TenantResolverResult.Unresolved();
/// <summary>
/// Automatically resolves the tenant if the tenant ID is not null.
/// </summary>

View file

@ -30,9 +30,10 @@ public class TenantResolverContext
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <returns>The found tenant or null if no tenant with the provided ID exists.</returns>
public Tenant? FindTenant(string tenantId)
public Tenant? FindTenant(string? tenantId)
{
return _tenantsDictionary.TryGetValue(tenantId, out var tenant) ? tenant : null;
var normalizedId = tenantId.NormalizeTenantId();
return _tenantsDictionary.TryGetValue(normalizedId, out var tenant) ? tenant : null;
}
/// <summary>

View file

@ -1,7 +1,7 @@
namespace Elsa.Common.Multitenancy;
/// <summary>
/// A strategy for resolving the current tenant. This is called the tenant initializer.
/// A strategy for resolving the current tenant, called from the tenant initializer.
/// </summary>
public interface ITenantResolver
{

View file

@ -10,6 +10,11 @@ namespace Elsa.Common.Multitenancy;
[UsedImplicitly]
public class Tenant : Entity
{
/// <summary>
/// The ID used for the default tenant.
/// </summary>
public const string DefaultTenantId = "";
/// <summary>
/// Gets or sets the name.
/// </summary>
@ -19,10 +24,10 @@ public class Tenant : Entity
/// Gets or sets the configuration.
/// </summary>
public IConfiguration Configuration { get; set; } = new ConfigurationBuilder().Build();
public static readonly Tenant Default = new()
{
Id = null!,
Id = DefaultTenantId,
Name = "Default"
};
}

View file

@ -5,6 +5,11 @@ namespace Elsa.Common.Multitenancy;
[UsedImplicitly]
public static class TenantsProviderExtensions
{
/// <summary>
/// Normalizes a tenant ID by converting null to empty string, ensuring consistency with the default tenant convention.
/// </summary>
public static string NormalizeTenantId(this string? tenantId) => tenantId ?? Tenant.DefaultTenantId;
public static async Task<Tenant?> FindByIdAsync(this ITenantsProvider tenantsProvider, string id, CancellationToken cancellationToken = default)
{
var filter = new TenantFilter

View file

@ -3,26 +3,38 @@ namespace Elsa.Common.Multitenancy;
/// <summary>
/// Represents the result of a tenant resolution.
/// </summary>
/// <param name="TenantId">The resolved tenant.</param>
public record TenantResolverResult(string? TenantId)
public record TenantResolverResult
{
private readonly bool _isResolved;
private TenantResolverResult(string? tenantId, bool isResolved)
{
TenantId = tenantId;
_isResolved = isResolved;
}
/// <summary>
/// The normalized tenant ID. Returns null if unresolved.
/// </summary>
public string? TenantId => _isResolved ? field.NormalizeTenantId() : null;
/// <summary>
/// Creates a new instance of <see cref="TenantResolverResult"/> representing a resolved tenant.
/// </summary>
/// <param name="tenantId">The resolved tenant.</param>
/// <returns>A new instance of <see cref="TenantResolverResult"/> representing a resolved tenant.</returns>
public static TenantResolverResult Resolved(string tenantId) => new(tenantId);
public static TenantResolverResult Resolved(string? tenantId) => new(tenantId, true);
/// <summary>
/// Creates a new instance of <see cref="TenantResolverResult"/> representing an unresolved tenant.
/// </summary>
/// <returns>A new instance of <see cref="TenantResolverResult"/> representing an unresolved tenant.</returns>
public static TenantResolverResult Unresolved() => new(default(string?));
public static TenantResolverResult Unresolved() => new(null, false);
/// <summary>
/// Gets a value indicating whether the tenant has been resolved.
/// </summary>
public bool IsResolved => TenantId != null;
public bool IsResolved => _isResolved;
public string ResolveTenantId() => TenantId ?? throw new InvalidOperationException("Tenant has not been resolved.");
}

View file

@ -1,17 +0,0 @@
using Elsa.Persistence.EFCore.EntityHandlers;
using Elsa.Features.Abstractions;
using Elsa.Features.Services;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Persistence.EFCore;
/// <inheritdoc />
public class CommonPersistenceFeature(IModule module) : FeatureBase(module)
{
/// <inheritdoc />
public override void Apply()
{
Services.AddScoped<IEntitySavingHandler, ApplyTenantId>();
Services.AddScoped<IEntityModelCreatingHandler, SetTenantIdFilter>();
}
}

View file

@ -2,6 +2,7 @@ using Elsa.Common.Entities;
using Elsa.Extensions;
using Elsa.Features.Abstractions;
using Elsa.Features.Services;
using Elsa.Persistence.EFCore.EntityHandlers;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
@ -60,6 +61,9 @@ public abstract class PersistenceFeatureBase<TFeature, TDbContext>(IModule modul
{
options.RunMigrations[typeof(TDbContext)] = RunMigrations;
});
Services.AddScoped<IEntitySavingHandler, ApplyTenantId>();
Services.AddScoped<IEntityModelCreatingHandler, SetTenantIdFilter>();
}
protected virtual void ConfigureMigrations()

View file

@ -30,10 +30,10 @@ namespace Elsa.Persistence.EFCore.MySql.Migrations.Runtime
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId",
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId_TenantId",
schema: _schema.Schema,
table: "Triggers",
columns: new[] { "WorkflowDefinitionId", "Hash", "ActivityId" },
columns: new[] { "WorkflowDefinitionId", "Hash", "ActivityId", "TenantId" },
unique: true);
}
@ -41,7 +41,7 @@ namespace Elsa.Persistence.EFCore.MySql.Migrations.Runtime
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId",
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId_TenantId",
schema: _schema.Schema,
table: "Triggers");

View file

@ -19,10 +19,10 @@ namespace Elsa.Persistence.EFCore.Oracle.Migrations.Runtime
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId",
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId_TenantId",
schema: _schema.Schema,
table: "Triggers",
columns: new[] { "WorkflowDefinitionId", "Hash", "ActivityId" },
columns: new[] { "WorkflowDefinitionId", "Hash", "ActivityId", "TenantId" },
unique: true,
filter: "\"Hash\" IS NOT NULL");
}
@ -31,7 +31,7 @@ namespace Elsa.Persistence.EFCore.Oracle.Migrations.Runtime
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId",
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId_TenantId",
schema: _schema.Schema,
table: "Triggers");
}

View file

@ -19,10 +19,10 @@ namespace Elsa.Persistence.EFCore.PostgreSql.Migrations.Runtime
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId",
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId_TenantId",
schema: _schema.Schema,
table: "Triggers",
columns: new[] { "WorkflowDefinitionId", "Hash", "ActivityId" },
columns: new[] { "WorkflowDefinitionId", "Hash", "ActivityId", "TenantId" },
unique: true);
}
@ -30,7 +30,7 @@ namespace Elsa.Persistence.EFCore.PostgreSql.Migrations.Runtime
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId",
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId_TenantId",
schema: _schema.Schema,
table: "Triggers");
}

View file

@ -317,9 +317,9 @@ namespace Elsa.Persistence.EFCore.SqlServer.Migrations.Runtime
b.HasIndex("WorkflowDefinitionVersionId")
.HasDatabaseName("IX_StoredTrigger_WorkflowDefinitionVersionId");
b.HasIndex("WorkflowDefinitionId", "Hash", "ActivityId")
b.HasIndex("WorkflowDefinitionId", "Hash", "ActivityId", "TenantId")
.IsUnique()
.HasDatabaseName("IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId")
.HasDatabaseName("IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId_TenantId")
.HasFilter("[Hash] IS NOT NULL");
b.ToTable("Triggers", "Elsa");

View file

@ -18,6 +18,16 @@ namespace Elsa.Persistence.EFCore.SqlServer.Migrations.Runtime
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Drop old index if it exists (before TenantId was added)
migrationBuilder.Sql($@"
IF EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId'
AND object_id = OBJECT_ID('{_schema.Schema}.Triggers'))
BEGIN
DROP INDEX [IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId]
ON [{_schema.Schema}].[Triggers]
END
");
migrationBuilder.AlterColumn<string>(
name: "ActivityId",
schema: _schema.Schema,
@ -28,10 +38,10 @@ namespace Elsa.Persistence.EFCore.SqlServer.Migrations.Runtime
oldType: "nvarchar(max)");
migrationBuilder.CreateIndex(
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId",
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId_TenantId",
schema: _schema.Schema,
table: "Triggers",
columns: new[] { "WorkflowDefinitionId", "Hash", "ActivityId" },
columns: new[] { "WorkflowDefinitionId", "Hash", "ActivityId", "TenantId" },
unique: true,
filter: "[Hash] IS NOT NULL");
}
@ -40,7 +50,7 @@ namespace Elsa.Persistence.EFCore.SqlServer.Migrations.Runtime
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId",
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId_TenantId",
schema: _schema.Schema,
table: "Triggers");

View file

@ -314,9 +314,9 @@ namespace Elsa.Persistence.EFCore.SqlServer.Migrations.Runtime
b.HasIndex("WorkflowDefinitionVersionId")
.HasDatabaseName("IX_StoredTrigger_WorkflowDefinitionVersionId");
b.HasIndex("WorkflowDefinitionId", "Hash", "ActivityId")
b.HasIndex("WorkflowDefinitionId", "Hash", "ActivityId", "TenantId")
.IsUnique()
.HasDatabaseName("IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId")
.HasDatabaseName("IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId_TenantId")
.HasFilter("[Hash] IS NOT NULL");
b.ToTable("Triggers", "Elsa");

View file

@ -19,10 +19,10 @@ namespace Elsa.Persistence.EFCore.Sqlite.Migrations.Runtime
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId",
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId_TenantId",
schema: _schema.Schema,
table: "Triggers",
columns: new[] { "WorkflowDefinitionId", "Hash", "ActivityId" },
columns: new[] { "WorkflowDefinitionId", "Hash", "ActivityId", "TenantId" },
unique: true);
}
@ -30,7 +30,7 @@ namespace Elsa.Persistence.EFCore.Sqlite.Migrations.Runtime
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId",
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId_TenantId",
schema: _schema.Schema,
table: "Triggers");
}

View file

@ -125,15 +125,16 @@ public class Configurations :
builder.HasIndex(x => x.TenantId).HasDatabaseName($"IX_{nameof(StoredTrigger)}_{nameof(StoredTrigger.TenantId)}");
// Add unique constraint to prevent duplicate trigger registrations in multi-engine environments
// A trigger is uniquely identified by WorkflowDefinitionId + Hash + ActivityId
// A trigger is uniquely identified by WorkflowDefinitionId + Hash + ActivityId + TenantId
builder.HasIndex(x => new
{
x.WorkflowDefinitionId,
x.Hash,
x.ActivityId
x.ActivityId,
x.TenantId
})
.IsUnique()
.HasDatabaseName($"IX_{nameof(StoredTrigger)}_Unique_{nameof(StoredTrigger.WorkflowDefinitionId)}_{nameof(StoredTrigger.Hash)}_{nameof(StoredTrigger.ActivityId)}");
.HasDatabaseName($"IX_{nameof(StoredTrigger)}_Unique_{nameof(StoredTrigger.WorkflowDefinitionId)}_{nameof(StoredTrigger.Hash)}_{nameof(StoredTrigger.ActivityId)}_{nameof(StoredTrigger.TenantId)}");
}
/// <inheritdoc />

View file

@ -1,5 +1,6 @@
using Elsa.Features.Services;
using Elsa.Tenants.Features;
using JetBrains.Annotations;
// ReSharper disable once CheckNamespace
namespace Elsa.Tenants.Extensions;
@ -7,32 +8,39 @@ namespace Elsa.Tenants.Extensions;
/// <summary>
/// Extensions for <see cref="IModule"/> that installs the <see cref="TenantsFeature"/> feature.
/// </summary>
[UsedImplicitly]
public static class ModuleExtensions
{
/// <summary>
/// Installs and configures the <see cref="TenantsFeature"/> feature.
/// </summary>
public static IModule UseTenants(this IModule module, Action<TenantsFeature>? configure = default)
[UsedImplicitly]
public static IModule UseTenants(this IModule module, Action<TenantsFeature>? configure = null)
{
module.Configure(configure);
return module;
}
/// <summary>
/// Installs and configures the <see cref="TenantManagementEndpointsFeature"/> feature.
/// </summary>
public static TenantsFeature UseTenantManagementEndpoints(this TenantsFeature feature, Action<TenantManagementEndpointsFeature>? configure = default)
extension(TenantsFeature feature)
{
feature.Module.Configure(configure);
return feature;
}
/// <summary>
/// Installs and configures the <see cref="TenantManagementEndpointsFeature"/> feature.
/// </summary>
[UsedImplicitly]
public TenantsFeature UseTenantManagementEndpoints(Action<TenantManagementEndpointsFeature>? configure = null)
{
feature.Module.Configure(configure);
return feature;
}
/// <summary>
/// Installs and configures the <see cref="TenantManagementFeature"/> feature.
/// </summary>
public static TenantsFeature UseTenantManagement(this TenantsFeature feature, Action<TenantManagementFeature>? configure = default)
{
feature.Module.Configure(configure);
return feature;
/// <summary>
/// Installs and configures the <see cref="TenantManagementFeature"/> feature.
/// </summary>
[UsedImplicitly]
public TenantsFeature UseTenantManagement(Action<TenantManagementFeature>? configure = null)
{
feature.Module.Configure(configure);
return feature;
}
}
}

View file

@ -17,7 +17,8 @@ public class DefaultTenantResolverPipelineInvoker(
public async Task<Tenant?> InvokePipelineAsync(CancellationToken cancellationToken = default)
{
var resolutionPipeline = options.Value.TenantResolverPipelineBuilder.Build(serviceProvider);
var tenantsDictionary = (await tenantsProvider.ListAsync(cancellationToken)).ToDictionary(x => x.Id);
var tenants = await tenantsProvider.ListAsync(cancellationToken);
var tenantsDictionary = tenants.ToDictionary(x => x.Id.NormalizeTenantId());
var context = new TenantResolverContext(tenantsDictionary, cancellationToken);
foreach (var resolver in resolutionPipeline)

View file

@ -47,7 +47,7 @@ public class ClrWorkflowsProvider(
{
Id = id,
DefinitionId = definitionId,
TenantId = tenantId?.NullIfEmpty()
TenantId = tenantId.NormalizeTenantId()
};
var materializerContext = new ClrWorkflowMaterializerContext(workflowBuilder.GetType());

View file

@ -1,5 +1,6 @@
using Elsa.Common;
using Elsa.Common.Models;
using Elsa.Common.Multitenancy;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Management;
using Elsa.Workflows.Management.Entities;
@ -19,6 +20,7 @@ public class DefaultWorkflowDefinitionStorePopulator : IWorkflowDefinitionStoreP
private readonly IPayloadSerializer _payloadSerializer;
private readonly ISystemClock _systemClock;
private readonly IIdentityGraphService _identityGraphService;
private readonly ITenantAccessor _tenantAccessor;
private readonly ILogger<DefaultWorkflowDefinitionStorePopulator> _logger;
private readonly SemaphoreSlim _semaphore = new(1, 1);
@ -33,6 +35,7 @@ public class DefaultWorkflowDefinitionStorePopulator : IWorkflowDefinitionStoreP
IPayloadSerializer payloadSerializer,
ISystemClock systemClock,
IIdentityGraphService identityGraphService,
ITenantAccessor tenantAccessor,
ILogger<DefaultWorkflowDefinitionStorePopulator> logger)
{
_workflowDefinitionProviders = workflowDefinitionProviders;
@ -42,6 +45,7 @@ public class DefaultWorkflowDefinitionStorePopulator : IWorkflowDefinitionStoreP
_payloadSerializer = payloadSerializer;
_systemClock = systemClock;
_identityGraphService = identityGraphService;
_tenantAccessor = tenantAccessor;
_logger = logger;
}
@ -56,6 +60,7 @@ public class DefaultWorkflowDefinitionStorePopulator : IWorkflowDefinitionStoreP
{
var providers = _workflowDefinitionProviders();
var workflowDefinitions = new List<WorkflowDefinition>();
var currentTenantId = (_tenantAccessor.Tenant?.Id).NormalizeTenantId();
foreach (var provider in providers)
{
@ -63,6 +68,18 @@ public class DefaultWorkflowDefinitionStorePopulator : IWorkflowDefinitionStoreP
foreach (var result in results)
{
// Only import workflows belonging to the current tenant.
if (result.Workflow.Identity.TenantId.NormalizeTenantId() != currentTenantId)
{
_logger.LogDebug(
"Skipping adding workflow {WorkflowId} from provider {Provider} because it belongs to tenant '{WorkflowTenantId}' but current tenant is '{CurrentTenantId}'",
result.Workflow.Identity.DefinitionId,
provider.Name,
result.Workflow.Identity.TenantId,
currentTenantId);
continue;
}
var workflowDefinition = await AddAsync(result, indexTriggers, cancellationToken);
workflowDefinitions.Add(workflowDefinition);
}
@ -128,7 +145,9 @@ public class DefaultWorkflowDefinitionStorePopulator : IWorkflowDefinitionStoreP
{
// NEW WAY: OriginalSource is provided
// For JSON workflows, we still need to populate StringData with the serialized root for backwards compatibility
stringData = materializedWorkflow.MaterializerName == "Json" ? _activitySerializer.Serialize(workflow.Root) :
stringData = materializedWorkflow.MaterializerName == "Json"
? _activitySerializer.Serialize(workflow.Root)
:
// For new formats (ElsaScript, YAML, etc.), only OriginalSource is needed
// StringData can be null as these materializers only use OriginalSource
null;

View file

@ -131,16 +131,17 @@ public class WorkflowServer(Infrastructure infrastructure, string url) : WebAppl
builder.ConfigureTestServices(services =>
{
// Decorate IDistributedLockProvider with TestDistributedLockProvider so tests use it
services.Decorate<IDistributedLockProvider, TestDistributedLockProvider>();
// Also register TestDistributedLockProvider as itself so tests can access it directly for configuration
// Decorate IDistributedLockProvider with SelectiveMockLockProvider
// This allows tests to selectively mock specific locks without affecting background operations
services.Decorate<IDistributedLockProvider, SelectiveMockLockProvider>();
// Register SelectiveMockLockProvider as itself so tests can access it for configuration
services.AddSingleton(sp =>
{
var provider = sp.GetRequiredService<IDistributedLockProvider>();
if (provider is not TestDistributedLockProvider testProvider)
throw new InvalidOperationException($"Expected IDistributedLockProvider to be decorated with TestDistributedLockProvider, but got {provider.GetType().Name}");
return testProvider;
if (provider is not SelectiveMockLockProvider selectiveProvider)
throw new InvalidOperationException($"Expected IDistributedLockProvider to be decorated with SelectiveMockLockProvider, but got {provider.GetType().Name}");
return selectiveProvider;
});
services
@ -154,7 +155,6 @@ public class WorkflowServer(Infrastructure infrastructure, string url) : WebAppl
.AddWorkflowsProvider<TestWorkflowProvider>()
.AddNotificationHandlersFrom<WorkflowEventHandlers>()
.Decorate<IChangeTokenSignaler, EventPublishingChangeTokenSignaler>()
.Decorate<IDistributedLockProvider, TestDistributedLockProvider>()
;
});
}

View file

@ -19,10 +19,10 @@ namespace Elsa.Workflows.ComponentTests.Scenarios.DistributedLockResilience;
public class DistributedLockResilienceTests(App app) : AppComponentTest(app)
{
private const int MaxRetryAttempts = 3;
// The IDistributedLockProvider is decorated with TestDistributedLockProvider in WorkflowServer.ConfigureTestServices
// This cast is safe because the decorator pattern ensures TestDistributedLockProvider wraps the actual provider
private TestDistributedLockProvider MockProvider => (TestDistributedLockProvider)Scope.ServiceProvider.GetRequiredService<IDistributedLockProvider>();
// Selective mock provider - only mocks specific locks, not all locks globally
private SelectiveMockLockProvider SelectiveMockProvider => Scope.ServiceProvider.GetRequiredService<SelectiveMockLockProvider>();
private ITransientExceptionDetector TransientExceptionDetector => Scope.ServiceProvider.GetRequiredService<ITransientExceptionDetector>();
private ILogger<DistributedLockResilienceTests> Logger => Scope.ServiceProvider.GetRequiredService<ILogger<DistributedLockResilienceTests>>();
private DistributedLockingOptions LockOptions => Scope.ServiceProvider.GetRequiredService<IOptions<DistributedLockingOptions>>().Value;
@ -34,22 +34,25 @@ public class DistributedLockResilienceTests(App app) : AppComponentTest(app)
[InlineData(4, 4, true)] // Four failures, exhausts retries (MaxRetryAttempts = 3)
public async Task AcquireLockWithRetry_AcquisitionFailures_BehavesAsExpected(int failureCount, int expectedAttemptCount, bool shouldThrow)
{
// Arrange
MockProvider.Reset();
MockProvider.FailAcquisitionTimes(failureCount);
// Arrange - Mock this specific lock only
var lockName = $"test-lock-{failureCount}";
var mockProvider = SelectiveMockProvider.MockLock(lockName);
mockProvider.Reset();
mockProvider.FailAcquisitionTimes(failureCount);
// Act & Assert
if (shouldThrow)
{
await Assert.ThrowsAsync<TimeoutException>(async () => await AcquireLockWithRetryAsync($"test-lock-{failureCount}"));
await Assert.ThrowsAsync<TimeoutException>(async () => await AcquireLockWithRetryAsync(lockName, mockProvider));
}
else
{
await using var handle = await AcquireLockWithRetryAsync($"test-lock-{failureCount}");
await using var handle = await AcquireLockWithRetryAsync(lockName, mockProvider);
Assert.NotNull(handle);
}
Assert.Equal(expectedAttemptCount, MockProvider.AcquisitionAttemptCount);
// Assert exact count - only this lock is mocked
Assert.Equal(expectedAttemptCount, mockProvider.AcquisitionAttemptCount);
}
[Theory]
@ -62,10 +65,11 @@ public class DistributedLockResilienceTests(App app) : AppComponentTest(app)
var workflowClient = await CreateWorkflowClientAsync();
var workflowInstanceId = workflowClient.WorkflowInstanceId;
// Reset and configure failures for this specific workflow instance's lock
MockProvider.Reset();
MockProvider.FailAcquisitionTimesForLock($"workflow-instance:{workflowInstanceId}", failureCount);
var attemptCountBefore = MockProvider.AcquisitionAttemptCount;
// Configure failures for this specific workflow instance's lock only
var lockPrefix = $"workflow-instance:{workflowInstanceId}";
var mockProvider = SelectiveMockProvider.MockLock(lockPrefix);
mockProvider.Reset();
mockProvider.FailAcquisitionTimes(failureCount);
// Now run the instance with the configured lock failures
var runRequest = new RunWorkflowInstanceRequest();
@ -82,34 +86,42 @@ public class DistributedLockResilienceTests(App app) : AppComponentTest(app)
Assert.NotNull(response);
}
// Verify retries occurred - check the delta from before the operation to account for background noise
var expectedAttempts = failureCount + 1; // failures + 1 success (or final failure for shouldThrow case)
AssertMinimumAttempts(MockProvider.AcquisitionAttemptCount - attemptCountBefore, expectedAttempts, "acquisition");
// Assert exact count - only this specific workflow instance lock is mocked
// When shouldThrow=true, all attempts fail: MaxRetryAttempts+1 (initial + retries)
// When shouldThrow=false, we succeed after failures: failureCount+1 (failures + success)
var expectedAttempts = shouldThrow ? MaxRetryAttempts + 1 : failureCount + 1;
Assert.Equal(expectedAttempts, mockProvider.AcquisitionAttemptCount);
}
[Fact]
public async Task RunInstanceAsync_TransientReleaseFailure_ShouldLogButNotThrow()
{
// Arrange
MockProvider.Reset();
var workflowClient = await CreateWorkflowClientAsync(createInstance: false);
// Configure failure after client creation to minimize background interference
MockProvider.FailReleaseOnce();
var releaseCountBefore = MockProvider.ReleaseAttemptCount;
// Create and run to get the workflow instance ID, then configure release failure for that lock
var request = CreateAndRunRequest();
// Mock the workflow-instance lock prefix (all workflow instance locks)
var mockProvider = SelectiveMockProvider.MockLock("workflow-instance:");
mockProvider.Reset();
mockProvider.FailReleaseOnce();
// Act - Release failure should be caught and logged, not thrown
var response = await workflowClient.CreateAndRunInstanceAsync(CreateAndRunRequest());
var response = await workflowClient.CreateAndRunInstanceAsync(request);
// Assert
Assert.NotNull(response);
Assert.NotNull(response.WorkflowInstanceId);
AssertMinimumAttempts(MockProvider.ReleaseAttemptCount - releaseCountBefore, 1, "release");
// Verify at least one release occurred
Assert.True(mockProvider.ReleaseAttemptCount >= 1,
$"Expected at least 1 release attempt, but got {mockProvider.ReleaseAttemptCount}");
}
private async Task<IDistributedSynchronizationHandle?> AcquireLockWithRetryAsync(string lockName) =>
private async Task<IDistributedSynchronizationHandle?> AcquireLockWithRetryAsync(string lockName, TestDistributedLockProvider mockProvider) =>
await RetryPipeline.ExecuteAsync(async ct =>
await MockProvider.AcquireLockAsync(lockName, LockOptions.LockAcquisitionTimeout, ct),
await mockProvider.CreateLock(lockName).AcquireAsync(LockOptions.LockAcquisitionTimeout, ct),
CancellationToken.None);
/// <summary>
@ -138,10 +150,6 @@ public class DistributedLockResilienceTests(App app) : AppComponentTest(app)
WorkflowDefinitionHandle = WorkflowDefinitionHandle.ByDefinitionId(SimpleWorkflow.DefinitionId, VersionOptions.Latest)
};
private static void AssertMinimumAttempts(int actualAttempts, int expectedAttempts, string attemptType) =>
Assert.True(actualAttempts >= expectedAttempts,
$"Expected at least {expectedAttempts} {attemptType} attempts, but got {actualAttempts}");
private static ResiliencePipeline CreateRetryPipeline(ITransientExceptionDetector transientExceptionDetector, ILogger logger) =>
new ResiliencePipelineBuilder()
.AddRetry(new()

View file

@ -0,0 +1,104 @@
using Medallion.Threading;
namespace Elsa.Workflows.ComponentTests.Scenarios.DistributedLockResilience.Mocks;
/// <summary>
/// A lock provider that selectively mocks specific locks while allowing others to use the real implementation.
///
/// WHY THIS IS NECESSARY:
/// - Workflow operations trigger background processes (trigger indexing, state persistence, etc.)
/// - These background operations acquire their own locks concurrently
/// - If we mock ALL locks globally, background operations can consume configured failures
/// - This makes test assertions unreliable and flaky
///
/// SOLUTION:
/// - Only mock locks matching specific prefixes configured by tests
/// - Background operations use real locks (not counted, not mocked)
/// - Test operations use mocked locks (counted, failures injected)
/// - Result: Deterministic, reliable test assertions
/// </summary>
public class SelectiveMockLockProvider : IDistributedLockProvider, IDisposable
{
private readonly IDistributedLockProvider _realProvider;
private readonly Dictionary<string, TestDistributedLockProvider> _mockProvidersByPrefix = new();
private readonly object _lock = new();
public SelectiveMockLockProvider(IDistributedLockProvider realProvider)
{
_realProvider = realProvider;
}
/// <summary>
/// Gets the real/inner provider being wrapped.
/// </summary>
public IDistributedLockProvider RealProvider => _realProvider;
/// <summary>
/// Configures mocking for locks matching the specified prefix.
/// Returns a test provider that allows configuring failures for these locks.
/// </summary>
public TestDistributedLockProvider MockLock(string lockNamePrefix)
{
lock (_lock)
{
if (!_mockProvidersByPrefix.TryGetValue(lockNamePrefix, out var mockProvider))
{
mockProvider = new TestDistributedLockProvider(_realProvider);
_mockProvidersByPrefix[lockNamePrefix] = mockProvider;
}
return mockProvider;
}
}
/// <summary>
/// Removes mocking for the specified lock prefix, allowing it to use the real provider.
/// </summary>
public void Unmock(string lockNamePrefix)
{
lock (_lock)
{
_mockProvidersByPrefix.Remove(lockNamePrefix);
}
}
/// <summary>
/// Clears all mock configurations, resetting to real provider for all locks.
/// </summary>
public void Reset()
{
lock (_lock)
{
foreach (var mockProvider in _mockProvidersByPrefix.Values)
{
mockProvider.Reset();
}
_mockProvidersByPrefix.Clear();
}
}
/// <summary>
/// Creates a lock that will be mocked if it matches a configured prefix, otherwise uses the real provider.
/// </summary>
public IDistributedLock CreateLock(string name)
{
lock (_lock)
{
// Check if this lock name matches any configured mock prefix
foreach (var (prefix, mockProvider) in _mockProvidersByPrefix)
{
if (name.StartsWith(prefix, StringComparison.Ordinal))
{
return mockProvider.CreateLock(name);
}
}
// No mock configured, use real provider
return _realProvider.CreateLock(name);
}
}
public void Dispose()
{
Reset();
}
}

View file

@ -15,6 +15,11 @@ public class TestDistributedLockProvider(IDistributedLockProvider innerProvider)
private int _releaseAttemptCount;
private string? _targetLockPrefix;
/// <summary>
/// Gets the inner/real lock provider that this test provider wraps.
/// </summary>
public IDistributedLockProvider InnerProvider => innerProvider;
public int AcquisitionAttemptCount => _acquisitionAttemptCount;
public int ReleaseAttemptCount => _releaseAttemptCount;

View file

@ -1,4 +1,5 @@
using Elsa.Common.Models;
using Elsa.Common.Multitenancy;
using Elsa.Workflows.ComponentTests.Abstractions;
using Elsa.Workflows.ComponentTests.Fixtures;
using Elsa.Workflows.Management;
@ -7,18 +8,114 @@ using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Workflows.ComponentTests.Scenarios.Multitenancy;
/// <summary>
/// Tests for multitenancy tenant ID normalization.
/// </summary>
public class MultitenancyTests(App app) : AppComponentTest(app)
{
[Fact(Skip = "Multitenancy disabled. This test doesn't work because not all workflows are assigned the Tenant1 tenant.")]
public async Task LoadingWorkflows_ShouldReturnWorkflows_FromCurrentTenant()
[Fact]
public void DefaultTenant_ShouldUseEmptyStringAsId()
{
// Assert
Assert.Equal(string.Empty, Tenant.DefaultTenantId);
Assert.Equal(Tenant.DefaultTenantId, Tenant.Default.Id);
}
[Fact]
public void NormalizeTenantId_WithNull_ShouldReturnEmptyString()
{
// Arrange
string? tenantId = null;
// Act
var normalizedId = tenantId.NormalizeTenantId();
// Assert
Assert.Equal(Tenant.DefaultTenantId, normalizedId);
Assert.Equal(string.Empty, normalizedId);
}
[Fact]
public void NormalizeTenantId_WithEmptyString_ShouldReturnEmptyString()
{
// Arrange
var tenantId = string.Empty;
// Act
var normalizedId = tenantId.NormalizeTenantId();
// Assert
Assert.Equal(Tenant.DefaultTenantId, normalizedId);
}
[Fact]
public void NormalizeTenantId_WithValidTenantId_ShouldReturnSameValue()
{
// Arrange
var tenantId = "tenant-123";
// Act
var normalizedId = tenantId.NormalizeTenantId();
// Assert
Assert.Equal("tenant-123", normalizedId);
}
[Fact]
public async Task WorkflowDefinitionStore_ShouldWorkWithTenantNormalization()
{
// Arrange
var store = Scope.ServiceProvider.GetRequiredService<IWorkflowDefinitionStore>();
var filter = new WorkflowDefinitionFilter
{
IsSystem = false,
VersionOptions = VersionOptions.Latest
};
// Act & Assert - Should not throw exceptions related to tenant ID handling
var workflows = await store.FindManyAsync(filter);
Assert.All(workflows, workflow => Assert.Equal("Tenant1", workflow.TenantId));
Assert.NotNull(workflows);
}
}
[Fact]
public void TenantResolverContext_FindTenant_WithNull_ShouldNormalize()
{
// Arrange
var defaultTenant = new Tenant { Id = Tenant.DefaultTenantId, Name = "Default" };
var tenant1 = new Tenant { Id = "tenant1", Name = "Tenant 1" };
var tenantsDictionary = new Dictionary<string, Tenant>
{
{ defaultTenant.Id, defaultTenant },
{ tenant1.Id, tenant1 }
};
var context = new TenantResolverContext(tenantsDictionary, CancellationToken.None);
// Act
string? nullTenantId = null;
var result = context.FindTenant(nullTenantId);
// Assert
Assert.NotNull(result);
Assert.Equal(Tenant.DefaultTenantId, result.Id);
}
[Fact]
public void TenantResolverContext_FindTenant_WithEmptyString_ShouldFindDefaultTenant()
{
// Arrange
var defaultTenant = new Tenant { Id = Tenant.DefaultTenantId, Name = "Default" };
var tenantsDictionary = new Dictionary<string, Tenant>
{
{ defaultTenant.Id, defaultTenant }
};
var context = new TenantResolverContext(tenantsDictionary, CancellationToken.None);
// Act
var result = context.FindTenant(string.Empty);
// Assert
Assert.NotNull(result);
Assert.Equal(Tenant.DefaultTenantId, result.Id);
Assert.Equal("Default", result.Name);
}
}

View file

@ -1,4 +1,5 @@
using Elsa.Testing.Shared;
using Elsa.Common.Multitenancy;
using Elsa.Testing.Shared;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Helpers;
using Elsa.Workflows.Management;
@ -29,7 +30,7 @@ public class Tests
DefinitionId: "WorkflowWithTrigger",
Version: 1,
Id: "1",
TenantId: "default"
TenantId: Tenant.DefaultTenantId
),
Root = new Event("Foo")
{

View file

@ -0,0 +1,49 @@
using Elsa.Common.Multitenancy;
namespace Elsa.Common.UnitTests.Multitenancy;
public class TenantIdNormalizationTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
public void NormalizeTenantId_WithNullOrEmpty_ReturnsDefaultTenantId(string? tenantId)
{
// Act
var result = tenantId.NormalizeTenantId();
// Assert
Assert.Equal(Tenant.DefaultTenantId, result);
Assert.Equal(string.Empty, result);
}
[Theory]
[InlineData("tenant1")]
[InlineData("tenant-abc-123")]
[InlineData("DEFAULT")]
[InlineData("my-custom-tenant")]
[InlineData(" ")] // Whitespace is not normalized
public void NormalizeTenantId_WithNonNullString_ReturnsOriginalValue(string tenantId)
{
// Act
var result = tenantId.NormalizeTenantId();
// Assert
Assert.Equal(tenantId, result);
}
[Fact]
public void DefaultTenantId_IsEmptyString()
{
// Assert
Assert.Equal(Tenant.DefaultTenantId, string.Empty);
}
[Fact]
public void DefaultTenant_UsesDefaultTenantId()
{
// Assert
Assert.Equal(Tenant.DefaultTenantId, Tenant.Default.Id);
Assert.Equal(string.Empty, Tenant.Default.Id);
}
}

View file

@ -0,0 +1,155 @@
using Elsa.Common.Multitenancy;
namespace Elsa.Common.UnitTests.Multitenancy;
public class TenantResolverContextTests
{
[Theory]
[InlineData(null, "Default")]
[InlineData("", "Default")]
[InlineData("tenant1", "Tenant 1")]
[InlineData("tenant2", "Tenant 2")]
public void FindTenant_ById_FindsCorrectTenant(string? tenantId, string expectedName)
{
// Arrange
var context = CreateContext();
// Act
var result = context.FindTenant(tenantId!);
// Assert
Assert.NotNull(result);
Assert.Equal(expectedName, result.Name);
}
[Fact]
public void FindTenant_WithNonExistentId_ReturnsNull()
{
// Arrange
var context = CreateContext();
// Act
var result = context.FindTenant("non-existent");
// Assert
Assert.Null(result);
}
[Theory]
[InlineData("Alpha", "tenant1", "Tenant Alpha")]
[InlineData("Beta", "tenant2", "Tenant Beta")]
public void FindTenant_WithPredicate_FindsMatchingTenant(string searchTerm, string expectedId, string expectedName)
{
// Arrange
var context = CreateContextWithNamedTenants();
// Act
var result = context.FindTenant(t => t.Name.Contains(searchTerm));
// Assert
Assert.NotNull(result);
Assert.Equal(expectedId, result.Id);
Assert.Equal(expectedName, result.Name);
}
[Fact]
public void FindTenant_WithPredicate_NoMatch_ReturnsNull()
{
// Arrange
var context = CreateContext();
// Act
var result = context.FindTenant(t => t.Name == "NonExistent");
// Assert
Assert.Null(result);
}
[Fact]
public void Constructor_StoresCancellationToken()
{
// Arrange
using var cts = new CancellationTokenSource();
// Act
var context = new TenantResolverContext(new Dictionary<string, Tenant>(), cts.Token);
// Assert
Assert.Equal(cts.Token, context.CancellationToken);
}
[Fact]
public void FindTenant_NormalizesNullAndEmptyStringToSameValue()
{
// Arrange
var context = CreateContext();
// Act
var resultFromNull = context.FindTenant((string?)null);
var resultFromEmptyString = context.FindTenant(string.Empty);
// Assert
Assert.NotNull(resultFromNull);
Assert.NotNull(resultFromEmptyString);
Assert.Same(resultFromNull, resultFromEmptyString);
}
// Helper methods
private static TenantResolverContext CreateContext()
{
var tenants = new Dictionary<string, Tenant>
{
{
Tenant.DefaultTenantId, new()
{
Id = Tenant.DefaultTenantId,
Name = "Default"
}
},
{
"tenant1", new()
{
Id = "tenant1",
Name = "Tenant 1"
}
},
{
"tenant2", new()
{
Id = "tenant2",
Name = "Tenant 2"
}
}
};
return new(tenants, CancellationToken.None);
}
private static TenantResolverContext CreateContextWithNamedTenants()
{
var tenants = new Dictionary<string, Tenant>
{
{
Tenant.DefaultTenantId, new()
{
Id = Tenant.DefaultTenantId,
Name = "Default"
}
},
{
"tenant1", new()
{
Id = "tenant1",
Name = "Tenant Alpha"
}
},
{
"tenant2", new()
{
Id = "tenant2",
Name = "Tenant Beta"
}
}
};
return new(tenants, CancellationToken.None);
}
}

View file

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Include>[Elsa.Tenants]*</Include>
<Threshold>0</Threshold>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\common\Elsa.Testing.Shared\Elsa.Testing.Shared.csproj" />
<ProjectReference Include="..\..\..\src\modules\Elsa.Tenants\Elsa.Tenants.csproj" />
<ProjectReference Include="..\..\..\src\modules\Elsa.Common\Elsa.Common.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,181 @@
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);
}
}

View file

@ -0,0 +1,111 @@
using Elsa.Workflows.Activities;
using Elsa.Workflows.Models;
namespace Elsa.Workflows.Core.UnitTests.Models;
public class ActivityConstructionResultTests
{
[Theory]
[InlineData(0, false)]
[InlineData(1, true)]
[InlineData(2, true)]
public void Constructor_WithVaryingExceptionCounts_SetsPropertiesCorrectly(int exceptionCount, bool expectedHasExceptions)
{
// Arrange
var activity = CreateActivity();
var exceptions = CreateExceptions(exceptionCount);
// Act
var result = new ActivityConstructionResult(activity, exceptions);
// Assert
Assert.Same(activity, result.Activity);
Assert.Equal(exceptionCount, result.Exceptions.Count());
Assert.Equal(expectedHasExceptions, result.HasExceptions);
}
[Fact]
public void Constructor_WithNullExceptions_TreatsAsEmpty()
{
// Arrange
var activity = CreateActivity();
// Act
var result = new ActivityConstructionResult(activity, null);
// Assert
Assert.Empty(result.Exceptions);
Assert.False(result.HasExceptions);
}
[Theory]
[InlineData(0, false)]
[InlineData(1, true)]
[InlineData(3, true)]
public void Cast_PreservesActivityAndExceptions(int exceptionCount, bool expectedHasExceptions)
{
// Arrange
var activity = CreateActivity();
var exceptions = CreateExceptions(exceptionCount);
var result = new ActivityConstructionResult(activity, exceptions);
// Act
var typedResult = result.Cast<WriteLine>();
// Assert
Assert.IsType<ActivityConstructionResult<WriteLine>>(typedResult);
Assert.Same(activity, typedResult.Activity);
Assert.Equal(exceptionCount, typedResult.Exceptions.Count());
Assert.Equal(expectedHasExceptions, typedResult.HasExceptions);
}
[Theory]
[InlineData(0, false)]
[InlineData(1, true)]
[InlineData(2, true)]
public void GenericConstructor_CreatesTypedResultWithInheritance(int exceptionCount, bool expectedHasExceptions)
{
// Arrange
var activity = CreateActivity();
var exceptions = CreateExceptions(exceptionCount);
// Act
var result = new ActivityConstructionResult<WriteLine>(activity, exceptions);
// Assert
Assert.Same(activity, result.Activity);
Assert.Equal(exceptionCount, result.Exceptions.Count());
Assert.Equal(expectedHasExceptions, result.HasExceptions);
Assert.IsAssignableFrom<ActivityConstructionResult>(result);
}
[Fact]
public void Exceptions_CanBeEnumerated()
{
// Arrange
var activity = CreateActivity();
var exceptions = CreateExceptions(3);
var result = new ActivityConstructionResult(activity, exceptions);
// Act & Assert
var count = 0;
foreach (var ex in result.Exceptions)
{
Assert.NotNull(ex);
count++;
}
Assert.Equal(3, count);
}
// Helper methods
private static WriteLine CreateActivity() => new("test");
private static List<Exception>? CreateExceptions(int count)
{
if (count == 0) return null;
return Enumerable.Range(1, count)
.Select(i => new InvalidOperationException($"Error {i}") as Exception)
.ToList();
}
}

View file

@ -1,10 +1,11 @@
using Elsa.Common;
using Elsa.Workflows.Activities;
using Elsa.Common.Multitenancy;
using Elsa.Workflows.Management;
using Elsa.Workflows.Management.Entities;
using Elsa.Workflows.Management.Filters;
using Elsa.Workflows.Models;
using Microsoft.Extensions.Logging;
using Open.Linq.AsyncExtensions;
using NSubstitute;
namespace Elsa.Workflows.Runtime.UnitTests.Services;
@ -18,25 +19,25 @@ public class DefaultWorkflowDefinitionStorePopulatorTests
public DefaultWorkflowDefinitionStorePopulatorTests()
{
_storeMock = Substitute.For<IWorkflowDefinitionStore>();
_storeMock.FindManyAsync(Arg.Any<WorkflowDefinitionFilter>(), Arg.Any<CancellationToken>())
.Returns(_workflowDefinitionsInStore);
_populator = new DefaultWorkflowDefinitionStorePopulator(() => new List<IWorkflowsProvider>(),
_storeMock.FindManyAsync(Arg.Any<WorkflowDefinitionFilter>(), Arg.Any<CancellationToken>()).Returns(_workflowDefinitionsInStore);
_populator = new(() => new List<IWorkflowsProvider>(),
Substitute.For<ITriggerIndexer>(),
_storeMock,
Substitute.For<IActivitySerializer>(),
Substitute.For<IPayloadSerializer>(),
Substitute.For<ISystemClock>(),
Substitute.For<IIdentityGraphService>(),
Substitute.For<ITenantAccessor>(),
Substitute.For<ILogger<DefaultWorkflowDefinitionStorePopulator>>());
}
[Fact(DisplayName = "When adding a new workflow it needs to be saved")]
public async Task AddOrUpdateCoreAsync_NewWorkflowDefinition_AddsWorkflowDefinition()
{
var workflow = new MaterializedWorkflow(new Workflow
var workflow = new MaterializedWorkflow(new()
{
Identity = new WorkflowIdentity("a", 7, "1"),
Publication = new WorkflowPublication(true, true)
Identity = new("a", 7, "1"),
Publication = new(true, true)
}, "Test", "Test");
await _populator.AddAsync(workflow);
@ -62,9 +63,9 @@ public class DefaultWorkflowDefinitionStorePopulatorTests
}
});
var workflow = new MaterializedWorkflow(new Workflow
var workflow = new MaterializedWorkflow(new()
{
Identity = new WorkflowIdentity("a", 1, "1"),
Identity = new("a", 1, "1"),
Inputs = new List<InputDefinition>
{
new()
@ -94,10 +95,10 @@ public class DefaultWorkflowDefinitionStorePopulatorTests
IsLatest = true,
IsPublished = true
});
var workflow = new MaterializedWorkflow(new Workflow
var workflow = new MaterializedWorkflow(new()
{
Identity = new WorkflowIdentity("a", 2, "2"),
Publication = new WorkflowPublication(workflowAddedIsLatest, workflowAddedIsPublished)
Identity = new("a", 2, "2"),
Publication = new(workflowAddedIsLatest, workflowAddedIsPublished)
}, "Test", "Test");
await _populator.AddAsync(workflow);
@ -119,11 +120,11 @@ public class DefaultWorkflowDefinitionStorePopulatorTests
IsPublished = true
});
var workflow = new MaterializedWorkflow(new Workflow
var workflow = new MaterializedWorkflow(new()
{
Identity = new WorkflowIdentity("a", 3, "1"),
Identity = new("a", 3, "1"),
Version = 1,
Publication = new WorkflowPublication(true, true)
Publication = new(true, true)
}, "Test", "Test");
await _populator.AddAsync(workflow);
@ -143,9 +144,9 @@ public class DefaultWorkflowDefinitionStorePopulatorTests
Version = 1,
});
var workflow = new MaterializedWorkflow(new Workflow
var workflow = new MaterializedWorkflow(new()
{
Identity = new WorkflowIdentity("a", 2, "1")
Identity = new("a", 2, "1")
}, "Test", "Test");
await _populator.AddAsync(workflow);
@ -166,10 +167,10 @@ public class DefaultWorkflowDefinitionStorePopulatorTests
IsLatest = true
});
var workflow = new MaterializedWorkflow(new Workflow
var workflow = new MaterializedWorkflow(new()
{
Identity = new WorkflowIdentity("a", 1, "1"),
Publication = new WorkflowPublication(true, true)
Identity = new("a", 1, "1"),
Publication = new(true, true)
}, "Test", "Test");
await _populator.AddAsync(workflow);
@ -195,10 +196,10 @@ public class DefaultWorkflowDefinitionStorePopulatorTests
}
});
var workflow = new MaterializedWorkflow(new Workflow
var workflow = new MaterializedWorkflow(new()
{
Identity = new WorkflowIdentity("a", 1, "1"),
Publication = new WorkflowPublication(true, true)
Identity = new("a", 1, "1"),
Publication = new(true, true)
}, "Test", "Test");
await _populator.AddAsync(workflow);
@ -227,4 +228,91 @@ public class DefaultWorkflowDefinitionStorePopulatorTests
await _storeMock.Received(count)
.SaveManyAsync(Arg.Any<IEnumerable<WorkflowDefinition>>(), Arg.Any<CancellationToken>());
}
[Fact(DisplayName = "PopulateStoreAsync imports workflows from current tenant")]
public async Task PopulateStoreAsync_CurrentTenantWorkflows_ImportsWorkflows()
{
var currentTenantId = "tenant-1";
var workflow1 = CreateMaterializedWorkflow("workflow-1", "id-1", currentTenantId);
var workflow2 = CreateMaterializedWorkflow("workflow-2", "id-2", currentTenantId);
var populator = CreatePopulatorWithTenant(currentTenantId, workflow1, workflow2);
var result = await populator.PopulateStoreAsync();
Assert.Equal(2, result.Count());
await _storeMock.Received(2).SaveManyAsync(Arg.Any<IEnumerable<WorkflowDefinition>>(), Arg.Any<CancellationToken>());
}
[Fact(DisplayName = "PopulateStoreAsync skips workflows from other tenants")]
public async Task PopulateStoreAsync_OtherTenantWorkflows_SkipsWorkflows()
{
var currentTenantId = "tenant-1";
var otherTenantId = "tenant-2";
var workflowCurrentTenant = CreateMaterializedWorkflow("workflow-1", "id-1", currentTenantId);
var workflowOtherTenant = CreateMaterializedWorkflow("workflow-2", "id-2", otherTenantId);
var populator = CreatePopulatorWithTenant(currentTenantId, workflowCurrentTenant, workflowOtherTenant);
var result = await populator.PopulateStoreAsync().ToList();
Assert.Single(result);
Assert.Equal("workflow-1", result.First().DefinitionId);
await _storeMock.Received(1).SaveManyAsync(Arg.Any<IEnumerable<WorkflowDefinition>>(), Arg.Any<CancellationToken>());
}
[Theory(DisplayName = "PopulateStoreAsync handles null/empty tenant IDs correctly")]
[InlineData(null, null, true)] // Both null - should import
[InlineData("", "", true)] // Both empty - should import
[InlineData(null, "", true)] // Normalized as same - should import
[InlineData("tenant-1", null, false)] // Different tenants - should skip
[InlineData("tenant-1", "", false)] // Different tenants - should skip
public async Task PopulateStoreAsync_NullOrEmptyTenantIds_HandlesCorrectly(string? currentTenantId, string? workflowTenantId, bool shouldImport)
{
var workflow = CreateMaterializedWorkflow("workflow-1", "id-1", workflowTenantId);
var populator = CreatePopulatorWithTenant(currentTenantId, workflow);
var result = await populator.PopulateStoreAsync();
if (shouldImport)
{
Assert.Single(result);
await _storeMock.Received(1).SaveManyAsync(Arg.Any<IEnumerable<WorkflowDefinition>>(), Arg.Any<CancellationToken>());
}
else
{
Assert.Empty(result);
await _storeMock.DidNotReceive().SaveManyAsync(Arg.Any<IEnumerable<WorkflowDefinition>>(), Arg.Any<CancellationToken>());
}
}
private MaterializedWorkflow CreateMaterializedWorkflow(string definitionId, string id, string? tenantId)
{
return new(new()
{
Identity = new(definitionId, 1, id, tenantId),
Publication = new(true, true)
}, "Test", "TestProvider");
}
private DefaultWorkflowDefinitionStorePopulator CreatePopulatorWithTenant(string? tenantId, params MaterializedWorkflow[] workflows)
{
var tenantAccessor = Substitute.For<ITenantAccessor>();
tenantAccessor.Tenant.Returns(tenantId != null ? new Tenant { Id = tenantId } : null);
var provider = Substitute.For<IWorkflowsProvider>();
provider.Name.Returns("TestProvider");
provider.GetWorkflowsAsync(Arg.Any<CancellationToken>())
.Returns(new ValueTask<IEnumerable<MaterializedWorkflow>>(workflows));
return new(
() => new List<IWorkflowsProvider> { provider },
Substitute.For<ITriggerIndexer>(),
_storeMock,
Substitute.For<IActivitySerializer>(),
Substitute.For<IPayloadSerializer>(),
Substitute.For<ISystemClock>(),
Substitute.For<IIdentityGraphService>(),
tenantAccessor,
Substitute.For<ILogger<DefaultWorkflowDefinitionStorePopulator>>());
}
}