From b645a1c0486fddd45a1b77eb9c56ca43b4d4e850 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Fri, 11 Sep 2026 16:44:13 -0700 Subject: [PATCH 001/169] Fix MongoDB VariableSerializer dropping StorageDriverType (#8047) * Fix MongoDB VariableSerializer using empty serialization type registry VariableSerializer constructed VariableMapper against SerializationTypeRegistry.CreateDefault(), an isolated empty registry. After 3.8 type-resolution hardening, storage driver type names such as WorkflowInstanceStorageDriver no longer resolve, so variables lose StorageDriverType and are not persisted on resume. Inject the DI-registered ISerializationTypeRegistry (populated from SerializationTypeOptions by Elsa features) and register VariableSerializer so MongoDB serializer setup can use it. Co-authored-by: Sipke Schoorstra * Fix VariableSerializer tests resolving Options.Create against Elsa.Options Co-authored-by: Sipke Schoorstra --------- Co-authored-by: Cursor Agent --- .../Features/MongoDbFeature.cs | 2 + .../ConfigureMongoDbSerializers.cs | 4 +- .../Serializers/VariableSerializer.cs | 8 +- .../VariableSerializerTests.cs | 110 ++++++++++++++++++ 4 files changed, 119 insertions(+), 5 deletions(-) create mode 100644 test/modules/persistence/Elsa.MongoDb.UnitTests/VariableSerializerTests.cs diff --git a/src/modules/persistence/Elsa.Persistence.MongoDb/Features/MongoDbFeature.cs b/src/modules/persistence/Elsa.Persistence.MongoDb/Features/MongoDbFeature.cs index 0983fd7b1c..13a71994c2 100644 --- a/src/modules/persistence/Elsa.Persistence.MongoDb/Features/MongoDbFeature.cs +++ b/src/modules/persistence/Elsa.Persistence.MongoDb/Features/MongoDbFeature.cs @@ -5,6 +5,7 @@ using Elsa.Persistence.MongoDb.HostedServices; using Elsa.Persistence.MongoDb.NamingStrategies; using Elsa.Persistence.MongoDb.Options; +using Elsa.Persistence.MongoDb.Serializers; using Elsa.Workflows.Activities.Flowchart.Models; using Elsa.Workflows.Runtime.Entities; using Microsoft.Extensions.DependencyInjection; @@ -51,6 +52,7 @@ public override void Apply() Services.TryAddScoped(); Services.AddScoped(CollectionNamingStrategy); + Services.TryAddSingleton(); RegisterClassMaps(); } diff --git a/src/modules/persistence/Elsa.Persistence.MongoDb/HostedServices/ConfigureMongoDbSerializers.cs b/src/modules/persistence/Elsa.Persistence.MongoDb/HostedServices/ConfigureMongoDbSerializers.cs index 9c6f1d753f..38eaf434fa 100644 --- a/src/modules/persistence/Elsa.Persistence.MongoDb/HostedServices/ConfigureMongoDbSerializers.cs +++ b/src/modules/persistence/Elsa.Persistence.MongoDb/HostedServices/ConfigureMongoDbSerializers.cs @@ -21,13 +21,13 @@ namespace Elsa.Persistence.MongoDb.HostedServices; /// It uses helper methods to register these serializers during the application's startup process. /// [UsedImplicitly] -public class ConfigureMongoDbSerializers(IPayloadSerializer payloadSerializer) : IHostedService +public class ConfigureMongoDbSerializers(IPayloadSerializer payloadSerializer, VariableSerializer variableSerializer) : IHostedService { public Task StartAsync(CancellationToken cancellationToken) { TryRegisterSerializer(typeof(object), new PolymorphicSerializer()); TryRegisterSerializer(typeof(Type), new TypeSerializer()); - TryRegisterSerializer(typeof(Variable), new VariableSerializer()); + TryRegisterSerializer(typeof(Variable), variableSerializer); TryRegisterSerializer(typeof(Version), new VersionSerializer()); TryRegisterSerializer(typeof(JsonElement), new JsonElementSerializer()); TryRegisterSerializer(typeof(JsonNode), new JsonNodeBsonConverter()); diff --git a/src/modules/persistence/Elsa.Persistence.MongoDb/Serializers/VariableSerializer.cs b/src/modules/persistence/Elsa.Persistence.MongoDb/Serializers/VariableSerializer.cs index f3c98fa81c..de6547949c 100644 --- a/src/modules/persistence/Elsa.Persistence.MongoDb/Serializers/VariableSerializer.cs +++ b/src/modules/persistence/Elsa.Persistence.MongoDb/Serializers/VariableSerializer.cs @@ -2,6 +2,7 @@ using Elsa.Workflows; using Elsa.Workflows.Memory; using Elsa.Workflows.Models; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using MongoDB.Bson; using MongoDB.Bson.Serialization; @@ -18,10 +19,11 @@ public class VariableSerializer : IBsonSerializer /// /// Initializes a new instance of the class. /// - public VariableSerializer() + /// The DI-registered serialization type registry used to resolve storage drivers and variable types. + /// The logger used by when type resolution fails. + public VariableSerializer(ISerializationTypeRegistry serializationTypeRegistry, ILogger? logger = null) { - var serializationTypeRegistry = SerializationTypeRegistry.CreateDefault(); - _mapper = new VariableMapper(serializationTypeRegistry, NullLogger.Instance); + _mapper = new VariableMapper(serializationTypeRegistry, logger ?? NullLogger.Instance); } /// diff --git a/test/modules/persistence/Elsa.MongoDb.UnitTests/VariableSerializerTests.cs b/test/modules/persistence/Elsa.MongoDb.UnitTests/VariableSerializerTests.cs new file mode 100644 index 0000000000..62d3051950 --- /dev/null +++ b/test/modules/persistence/Elsa.MongoDb.UnitTests/VariableSerializerTests.cs @@ -0,0 +1,110 @@ +using Elsa.Common.Serialization; +using Elsa.Extensions; +using Elsa.Persistence.MongoDb.Serializers; +using Elsa.Workflows; +using Elsa.Workflows.Memory; +using Elsa.Workflows.Models; +using Elsa.Workflows.Services; +using MongoDB.Bson; +using MongoDB.Bson.IO; +using MongoDB.Bson.Serialization; + +namespace Elsa.MongoDb.UnitTests; + +public class VariableSerializerTests +{ + [Fact(DisplayName = "VariableSerializer preserves StorageDriverType when using the populated serialization type registry")] + public void Serialize_WithPopulatedRegistry_RoundTripsWorkflowInstanceStorageDriver() + { + var serializer = CreateSerializer(CreateWorkflowsRegistry()); + var variable = CreateDialogIdVariable(); + + var restored = RoundTrip(serializer, variable); + + Assert.Equal(variable.Id, restored.Id); + Assert.Equal(variable.Name, restored.Name); + Assert.Equal("abc", restored.Value); + Assert.Equal(typeof(WorkflowInstanceStorageDriver), restored.StorageDriverType); + Assert.IsType>(restored); + } + + [Fact(DisplayName = "VariableSerializer resolves the 3.7 storageDriverTypeName assembly-qualified name through the populated registry")] + public void Deserialize_WithLegacyWorkflowInstanceStorageDriverTypeName_RestoresStorageDriverType() + { + var serializer = CreateSerializer(CreateWorkflowsRegistry()); + var legacyTypeName = typeof(WorkflowInstanceStorageDriver).GetSimpleAssemblyQualifiedName(); + var model = new VariableModel("dialogIdVariable", "DialogId", "String", "abc", legacyTypeName); + + var restored = DeserializeModel(serializer, model); + + Assert.Equal(typeof(WorkflowInstanceStorageDriver), restored.StorageDriverType); + Assert.Equal("DialogId", restored.Name); + Assert.Equal("abc", restored.Value); + } + + [Fact(DisplayName = "VariableSerializer loses StorageDriverType against an empty default registry (the 3.8.0 regression)")] + public void Serialize_WithEmptyDefaultRegistry_DropsStorageDriverType() + { + var serializer = CreateSerializer(SerializationTypeRegistry.CreateDefault()); + var variable = CreateDialogIdVariable(); + + var restored = RoundTrip(serializer, variable); + + Assert.Null(restored.StorageDriverType); + } + + [Fact(DisplayName = "VariableSerializer serializes null variables as BSON null")] + public void Serialize_NullVariable_RoundTripsAsNull() + { + var serializer = CreateSerializer(CreateWorkflowsRegistry()); + + var document = new BsonDocument(); + using (var writer = new BsonDocumentWriter(document)) + { + writer.WriteStartDocument(); + writer.WriteName("value"); + serializer.Serialize(BsonSerializationContext.CreateRoot(writer), null!); + writer.WriteEndDocument(); + } + + using var reader = new BsonDocumentReader(document); + reader.ReadStartDocument(); + reader.ReadName("value"); + var restored = serializer.Deserialize(BsonDeserializationContext.CreateRoot(reader)); + + Assert.Null(restored); + } + + private static VariableSerializer CreateSerializer(ISerializationTypeRegistry registry) => new(registry); + + private static ISerializationTypeRegistry CreateWorkflowsRegistry() + { + var options = new SerializationTypeOptions(); + options.AddTypeAliasWithLegacyName(nameof(WorkflowInstanceStorageDriver)); + options.AddTypeAliasWithLegacyName(nameof(MemoryStorageDriver)); + return new SerializationTypeRegistry(Microsoft.Extensions.Options.Options.Create(options)); + } + + private static Variable CreateDialogIdVariable() => + new("DialogId", "abc", "dialogIdVariable") + { + StorageDriverType = typeof(WorkflowInstanceStorageDriver) + }; + + private static Variable RoundTrip(VariableSerializer serializer, Variable variable) + { + var document = new BsonDocument(); + using (var writer = new BsonDocumentWriter(document)) + serializer.Serialize(BsonSerializationContext.CreateRoot(writer), variable); + + using var reader = new BsonDocumentReader(document); + return serializer.Deserialize(BsonDeserializationContext.CreateRoot(reader)); + } + + private static Variable DeserializeModel(VariableSerializer serializer, VariableModel model) + { + var document = model.ToBsonDocument(); + using var reader = new BsonDocumentReader(document); + return serializer.Deserialize(BsonDeserializationContext.CreateRoot(reader)); + } +} From 6b9f744bb6fc8a1e2ab0976a9a8d45dfb024f614 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Fri, 11 Sep 2026 16:53:25 -0700 Subject: [PATCH 002/169] Fix MongoDB JsonObject/JsonArray/JsonValue serialization (#8048) * Fix MongoDB JsonNode serializer for JsonObject/JsonArray/JsonValue. PolymorphicSerializer looks up serializers by concrete runtime type, but JsonNodeBsonConverter was registered only for the JsonNode base type. JsonObject values in workflow state then serialized via class-map fallback and failed to deserialize (no parameterless constructor). Register the converter for derived types, accept the legacy map format, and cover round-trips with unit tests. Fixes https://github.com/elsa-workflows/elsa-core/issues/8048 Co-authored-by: Sipke Schoorstra * Tighten JsonNode tagged-envelope detection. Require exactly two elements and a BSON string value for JsonObject/JsonArray so a document that merely has type/value keys is deserialized as a map. Co-authored-by: Sipke Schoorstra --------- Co-authored-by: Cursor Agent --- .../ConfigureMongoDbSerializers.cs | 5 +- .../Serializers/JsonNodeBsonSerializer.cs | 227 +++++++++++---- .../JsonNodeBsonConverterTests.cs | 268 ++++++++++++++++++ 3 files changed, 449 insertions(+), 51 deletions(-) create mode 100644 test/modules/persistence/Elsa.MongoDb.UnitTests/JsonNodeBsonConverterTests.cs diff --git a/src/modules/persistence/Elsa.Persistence.MongoDb/HostedServices/ConfigureMongoDbSerializers.cs b/src/modules/persistence/Elsa.Persistence.MongoDb/HostedServices/ConfigureMongoDbSerializers.cs index 38eaf434fa..a6b540a385 100644 --- a/src/modules/persistence/Elsa.Persistence.MongoDb/HostedServices/ConfigureMongoDbSerializers.cs +++ b/src/modules/persistence/Elsa.Persistence.MongoDb/HostedServices/ConfigureMongoDbSerializers.cs @@ -17,7 +17,8 @@ namespace Elsa.Persistence.MongoDb.HostedServices; /// /// This class implements and is responsible for registering serializers to handle /// specific types such as , , , , -/// , , and . +/// , (including , , +/// and ), and . /// It uses helper methods to register these serializers during the application's startup process. /// [UsedImplicitly] @@ -30,7 +31,7 @@ public Task StartAsync(CancellationToken cancellationToken) TryRegisterSerializer(typeof(Variable), variableSerializer); TryRegisterSerializer(typeof(Version), new VersionSerializer()); TryRegisterSerializer(typeof(JsonElement), new JsonElementSerializer()); - TryRegisterSerializer(typeof(JsonNode), new JsonNodeBsonConverter()); + JsonNodeBsonConverter.RegisterSerializers(); TryRegisterSerializer(typeof(FlowScope), new FlowScopeSerializer(payloadSerializer)); return Task.CompletedTask; diff --git a/src/modules/persistence/Elsa.Persistence.MongoDb/Serializers/JsonNodeBsonSerializer.cs b/src/modules/persistence/Elsa.Persistence.MongoDb/Serializers/JsonNodeBsonSerializer.cs index 8867440e35..428ed51e8c 100644 --- a/src/modules/persistence/Elsa.Persistence.MongoDb/Serializers/JsonNodeBsonSerializer.cs +++ b/src/modules/persistence/Elsa.Persistence.MongoDb/Serializers/JsonNodeBsonSerializer.cs @@ -2,19 +2,77 @@ using MongoDB.Bson; using MongoDB.Bson.IO; using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; +using static MongoDB.Bson.Serialization.BsonSerializer; namespace Elsa.Persistence.MongoDb.Serializers; /// /// Serializes a . /// -public class JsonNodeBsonConverter : IBsonSerializer +public class JsonNodeBsonConverter : JsonNodeBsonConverter +{ + private static int _providerRegistered; + + /// + /// Registers serializers for and the concrete types + /// , , and . + /// Also registers a serialization provider so internal + /// implementations are looked up without falling back to a class map. + /// + public static void RegisterSerializers() + { + if (Interlocked.CompareExchange(ref _providerRegistered, 1, 0) == 0) + RegisterSerializationProvider(new JsonNodeBsonSerializationProvider()); + + TryRegisterSerializer(typeof(JsonNode), new JsonNodeBsonConverter()); + TryRegisterSerializer(typeof(JsonObject), new JsonNodeBsonConverter()); + TryRegisterSerializer(typeof(JsonArray), new JsonNodeBsonConverter()); + TryRegisterSerializer(typeof(JsonValue), new JsonNodeBsonConverter()); + } +} + +/// +/// Serializes a of the specified nominal type. +/// must match the registered type so +/// BsonSerializer.LookupSerializer (used by ) +/// finds this converter for , , and . +/// +public class JsonNodeBsonConverter : IBsonSerializer where TNode : JsonNode { /// - public Type ValueType => typeof(JsonNode); + public Type ValueType => typeof(TNode); /// - public void Serialize(BsonSerializationContext context, BsonSerializationArgs args, JsonNode value) + public void Serialize(BsonSerializationContext context, BsonSerializationArgs args, TNode value) + { + SerializeJsonNode(context, value); + } + + /// + public TNode Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) + { + var node = DeserializeJsonNode(context); + if (node is null) + return null!; + if (node is TNode typed) + return typed; + + throw new BsonSerializationException($"Deserialized JsonNode of type '{node.GetType().FullName}' is not assignable to '{typeof(TNode).FullName}'."); + } + + /// + public void Serialize(BsonSerializationContext context, BsonSerializationArgs args, object value) + { + SerializeJsonNode(context, (JsonNode)value); + } + + object IBsonSerializer.Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) + { + return DeserializeJsonNode(context)!; + } + + private static void SerializeJsonNode(BsonSerializationContext context, JsonNode value) { if (value == null!) { @@ -67,69 +125,140 @@ public void Serialize(BsonSerializationContext context, BsonSerializationArgs ar context.Writer.WriteEndDocument(); } - public JsonNode Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) + private static JsonNode? DeserializeJsonNode(BsonDeserializationContext context) + { + var reader = context.Reader; + var bsonType = reader.GetCurrentBsonType(); + + switch (bsonType) + { + case BsonType.Null: + reader.ReadNull(); + return null; + case BsonType.Document: + return DeserializeDocument(BsonDocumentSerializer.Instance.Deserialize(context)); + case BsonType.Array: + return DeserializeLegacyArray(BsonArraySerializer.Instance.Deserialize(context)); + default: + return DeserializePrimitiveJsonValue(ReadPrimitive(reader, bsonType)); + } + } + + private static JsonNode DeserializeDocument(BsonDocument document) + { + if (IsTaggedJsonNode(document)) + return DeserializeTagged(document); + + // Legacy class-map / dictionary format written when only JsonNode was registered: + // { "DialogId": { "type": "JsonValue", "value": "..." }, ... } + var obj = new JsonObject(); + foreach (var element in document) + obj[element.Name] = DeserializeBsonValue(element.Value); + return obj; + } + + private static bool IsTaggedJsonNode(BsonDocument document) { - context.Reader.ReadStartDocument(); - var type = context.Reader.ReadString(); - context.Reader.ReadName(Utf8NameDecoder.Instance); + if (document.ElementCount != 2 || !document.Contains("type") || !document.Contains("value")) + return false; + + if (document["type"].BsonType != BsonType.String) + return false; + + return document["type"].AsString switch + { + "JsonObject" or "JsonArray" => document["value"].BsonType == BsonType.String, + "JsonValue" => true, + _ => false + }; + } + + private static JsonNode DeserializeTagged(BsonDocument document) + { + var type = document["type"].AsString; + var value = document["value"]; - JsonNode result; switch (type) { case "JsonObject": - var jsonObjectString = context.Reader.ReadString(); - result = JsonNode.Parse(jsonObjectString); - break; - case "JsonArray": - var jsonArrayString = context.Reader.ReadString(); - result = JsonNode.Parse(jsonArrayString); - break; - + return JsonNode.Parse(value.AsString)!; case "JsonValue": - var bsonType = context.Reader.GetCurrentBsonType(); - switch (bsonType) - { - case BsonType.String: - result = JsonValue.Create(context.Reader.ReadString()); - break; - case BsonType.Int32: - result = JsonValue.Create(context.Reader.ReadInt32()); - break; - case BsonType.Int64: - result = JsonValue.Create(context.Reader.ReadInt64()); - break; - case BsonType.Double: - result = JsonValue.Create(context.Reader.ReadDouble()); - break; - case BsonType.Boolean: - result = JsonValue.Create(context.Reader.ReadBoolean()); - break; - case BsonType.DateTime: - result = JsonValue.Create(context.Reader.ReadDateTime()); - break; - default: - throw new BsonSerializationException($"Unsupported BSON type: {bsonType}"); - } - break; - + return DeserializePrimitiveJsonValue(value); default: throw new BsonSerializationException($"Unsupported JsonNode type: {type}"); } + } - context.Reader.ReadEndDocument(); - return result!; + private static JsonNode? DeserializeBsonValue(BsonValue value) + { + return value.BsonType switch + { + BsonType.Null => null, + BsonType.Document => DeserializeDocument(value.AsBsonDocument), + BsonType.Array => DeserializeLegacyArray(value.AsBsonArray), + _ => DeserializePrimitiveJsonValue(value) + }; } - /// - public void Serialize(BsonSerializationContext context, BsonSerializationArgs args, object value) + private static JsonArray DeserializeLegacyArray(BsonArray array) { - Serialize(context, args, (JsonNode)value); + var result = new JsonArray(); + foreach (var item in array) + result.Add(DeserializeBsonValue(item)); + return result; } - object IBsonSerializer.Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) + private static JsonValue DeserializePrimitiveJsonValue(BsonValue value) { - return Deserialize(context, args); + switch (value.BsonType) + { + case BsonType.String: + return JsonValue.Create(value.AsString)!; + case BsonType.Int32: + return JsonValue.Create(value.AsInt32)!; + case BsonType.Int64: + return JsonValue.Create(value.AsInt64)!; + case BsonType.Double: + return JsonValue.Create(value.AsDouble)!; + case BsonType.Boolean: + return JsonValue.Create(value.AsBoolean)!; + case BsonType.DateTime: + return JsonValue.Create(value.AsBsonDateTime.MillisecondsSinceEpoch)!; + default: + throw new BsonSerializationException($"Unsupported BSON type: {value.BsonType}"); + } + } + + private static BsonValue ReadPrimitive(IBsonReader reader, BsonType bsonType) + { + return bsonType switch + { + BsonType.String => reader.ReadString(), + BsonType.Int32 => reader.ReadInt32(), + BsonType.Int64 => reader.ReadInt64(), + BsonType.Double => reader.ReadDouble(), + BsonType.Boolean => reader.ReadBoolean(), + BsonType.DateTime => new BsonDateTime(reader.ReadDateTime()), + _ => throw new BsonSerializationException($"Unsupported BSON type: {bsonType}") + }; } } +/// +/// Provides for any subclass, +/// including internal implementations whose runtime type is not +/// itself. +/// +public class JsonNodeBsonSerializationProvider : IBsonSerializationProvider +{ + /// + public IBsonSerializer? GetSerializer(Type type) + { + if (type is null || !typeof(JsonNode).IsAssignableFrom(type)) + return null; + + var serializerType = typeof(JsonNodeBsonConverter<>).MakeGenericType(type); + return (IBsonSerializer)Activator.CreateInstance(serializerType)!; + } +} diff --git a/test/modules/persistence/Elsa.MongoDb.UnitTests/JsonNodeBsonConverterTests.cs b/test/modules/persistence/Elsa.MongoDb.UnitTests/JsonNodeBsonConverterTests.cs new file mode 100644 index 0000000000..6e48fc688a --- /dev/null +++ b/test/modules/persistence/Elsa.MongoDb.UnitTests/JsonNodeBsonConverterTests.cs @@ -0,0 +1,268 @@ +using System.Text.Json.Nodes; +using Elsa.Extensions; +using Elsa.Persistence.MongoDb.Serializers; +using MongoDB.Bson; +using MongoDB.Bson.IO; +using MongoDB.Bson.Serialization; + +namespace Elsa.MongoDb.UnitTests; + +public class JsonNodeBsonConverterTests +{ + static JsonNodeBsonConverterTests() + { + JsonNodeBsonConverter.RegisterSerializers(); + } + + [Fact(DisplayName = "JsonObject round-trips through the tagged BSON format")] + public void JsonObject_RoundTrips_ThroughTaggedFormat() + { + var original = JsonNode.Parse("""{"DialogId":"abc","Count":42,"Nested":{"Ok":true}}""")!.AsObject(); + var serializer = new JsonNodeBsonConverter(); + + var restored = RoundTrip(serializer, original); + + Assert.Equal("abc", restored["DialogId"]!.GetValue()); + Assert.Equal(42, restored["Count"]!.GetValue()); + Assert.True(restored["Nested"]!["Ok"]!.GetValue()); + } + + [Fact(DisplayName = "JsonArray round-trips through the tagged BSON format")] + public void JsonArray_RoundTrips_ThroughTaggedFormat() + { + var original = JsonNode.Parse("""["a",1,true,{"k":"v"}]""")!.AsArray(); + var serializer = new JsonNodeBsonConverter(); + + var restored = RoundTrip(serializer, original); + + Assert.Equal(4, restored.Count); + Assert.Equal("a", restored[0]!.GetValue()); + Assert.Equal(1, restored[1]!.GetValue()); + Assert.True(restored[2]!.GetValue()); + Assert.Equal("v", restored[3]!["k"]!.GetValue()); + } + + [Fact(DisplayName = "JsonValue string round-trips through the tagged BSON format")] + public void JsonValue_String_RoundTrips_ThroughTaggedFormat() => + AssertJsonValueRoundTrip(JsonValue.Create("hello")!); + + [Fact(DisplayName = "JsonValue int round-trips through the tagged BSON format")] + public void JsonValue_Int_RoundTrips_ThroughTaggedFormat() => + AssertJsonValueRoundTrip(JsonValue.Create(42)!); + + [Fact(DisplayName = "JsonValue bool round-trips through the tagged BSON format")] + public void JsonValue_Bool_RoundTrips_ThroughTaggedFormat() => + AssertJsonValueRoundTrip(JsonValue.Create(true)!); + + [Fact(DisplayName = "JsonValue double round-trips through the tagged BSON format")] + public void JsonValue_Double_RoundTrips_ThroughTaggedFormat() => + AssertJsonValueRoundTrip(JsonValue.Create(1.5)!); + + [Fact(DisplayName = "JsonNode serializer round-trips a JsonObject")] + public void JsonNode_RoundTrips_JsonObject() + { + var original = JsonNode.Parse("""{"payload":{"DialogId":"xyz"}}""")!; + var serializer = new JsonNodeBsonConverter(); + + var restored = RoundTrip(serializer, original); + + Assert.IsType(restored); + Assert.Equal("xyz", restored["payload"]!["DialogId"]!.GetValue()); + } + + [Fact(DisplayName = "Null JsonNode serializes as BSON null")] + public void NullJsonNode_RoundTripsAsNull() + { + var serializer = new JsonNodeBsonConverter(); + var document = new BsonDocument(); + + using (var writer = new BsonDocumentWriter(document)) + { + writer.WriteStartDocument(); + writer.WriteName("value"); + serializer.Serialize(BsonSerializationContext.CreateRoot(writer), null!); + writer.WriteEndDocument(); + } + + using var reader = new BsonDocumentReader(document); + reader.ReadStartDocument(); + reader.ReadName("value"); + var restored = serializer.Deserialize(BsonDeserializationContext.CreateRoot(reader)); + + Assert.Null(restored); + } + + [Fact(DisplayName = "Legacy class-map JsonObject document deserializes without a parameterless constructor")] + public void Deserialize_LegacyMapFormat_RestoresJsonObject() + { + var legacy = new BsonDocument + { + { "DialogId", new BsonDocument { { "type", "JsonValue" }, { "value", "abc" } } }, + { "Count", new BsonDocument { { "type", "JsonValue" }, { "value", 7 } } }, + { "Nested", new BsonDocument { { "type", "JsonObject" }, { "value", """{"Ok":true}""" } } } + }; + var serializer = new JsonNodeBsonConverter(); + + using var reader = new BsonDocumentReader(legacy); + var restored = serializer.Deserialize(BsonDeserializationContext.CreateRoot(reader)); + + Assert.Equal("abc", restored["DialogId"]!.GetValue()); + Assert.Equal(7, restored["Count"]!.GetValue()); + Assert.True(restored["Nested"]!["Ok"]!.GetValue()); + } + + [Fact(DisplayName = "Legacy class-map JsonArray document deserializes")] + public void Deserialize_LegacyArrayFormat_RestoresJsonArray() + { + var legacy = new BsonArray + { + new BsonDocument { { "type", "JsonValue" }, { "value", "a" } }, + new BsonDocument { { "type", "JsonValue" }, { "value", 1 } } + }; + var serializer = new JsonNodeBsonConverter(); + var document = new BsonDocument { { "value", legacy } }; + + using var reader = new BsonDocumentReader(document); + reader.ReadStartDocument(); + reader.ReadName("value"); + var restored = serializer.Deserialize(BsonDeserializationContext.CreateRoot(reader)); + + Assert.Equal(2, restored.Count); + Assert.Equal("a", restored[0]!.GetValue()); + Assert.Equal(1, restored[1]!.GetValue()); + } + + [Fact(DisplayName = "PolymorphicSerializer round-trips JsonObject without the parameterless-constructor failure")] + public void PolymorphicSerializer_JsonObject_RoundTrips() + { + var original = JsonNode.Parse("""{"DialogId":"abc","Enabled":true}""")!.AsObject(); + var serializer = new PolymorphicSerializer(); + + var restored = RoundTripObject(serializer, original); + + var obj = Assert.IsType(restored); + Assert.Equal("abc", obj["DialogId"]!.GetValue()); + Assert.True(obj["Enabled"]!.GetValue()); + } + + [Fact(DisplayName = "PolymorphicSerializer round-trips JsonArray")] + public void PolymorphicSerializer_JsonArray_RoundTrips() + { + var original = JsonNode.Parse("""[1,"x"]""")!.AsArray(); + var serializer = new PolymorphicSerializer(); + + var restored = RoundTripObject(serializer, original); + + var array = Assert.IsType(restored); + Assert.Equal(1, array[0]!.GetValue()); + Assert.Equal("x", array[1]!.GetValue()); + } + + [Fact(DisplayName = "PolymorphicSerializer round-trips JsonValue")] + public void PolymorphicSerializer_JsonValue_RoundTrips() + { + var original = JsonValue.Create("hello")!; + var serializer = new PolymorphicSerializer(); + + var restored = RoundTripObject(serializer, original); + + var value = Assert.IsAssignableFrom(restored); + Assert.Equal("hello", value.GetValue()); + } + + [Fact(DisplayName = "PolymorphicSerializer deserializes a pre-fix JsonObject written in the legacy map format")] + public void PolymorphicSerializer_DeserializesLegacyJsonObjectMap() + { + var typeName = typeof(JsonObject).GetSimpleAssemblyQualifiedName(); + var document = new BsonDocument + { + { "$type", typeName }, + { + "$value", new BsonDocument + { + { "DialogId", new BsonDocument { { "type", "JsonValue" }, { "value", "from-legacy" } } }, + { "Count", new BsonDocument { { "type", "JsonValue" }, { "value", 3 } } } + } + } + }; + var serializer = new PolymorphicSerializer(); + + using var reader = new BsonDocumentReader(document); + var restored = serializer.Deserialize(BsonDeserializationContext.CreateRoot(reader)); + + var obj = Assert.IsType(restored); + Assert.Equal("from-legacy", obj["DialogId"]!.GetValue()); + Assert.Equal(3, obj["Count"]!.GetValue()); + } + + [Fact(DisplayName = "A type/value document that is not a two-field string envelope deserializes as a map")] + public void Deserialize_AmbiguousTypeValueDocument_IsMapNotEnvelope() + { + // {"type":"JsonObject","value":"{\"a\":1}"} with ElementCount == 2 and a BSON string + // value is a valid tagged envelope (covered by the round-trip tests). The false-positive + // risk is a class-map / user document that merely has type/value keys: + // extra elements, or value as a nested BSON document instead of a JSON string. + var extraElements = new BsonDocument + { + { "type", "JsonObject" }, + { "value", """{"a":1}""" }, + { "DialogId", "abc" } + }; + var nestedDocumentValue = new BsonDocument + { + { "type", "JsonObject" }, + { "value", new BsonDocument { { "a", 1 } } } + }; + var serializer = new JsonNodeBsonConverter(); + + var withExtra = DeserializeDocument(serializer, extraElements); + Assert.Equal("JsonObject", withExtra["type"]!.GetValue()); + Assert.Equal("""{"a":1}""", withExtra["value"]!.GetValue()); + Assert.Equal("abc", withExtra["DialogId"]!.GetValue()); + + var withNestedValue = DeserializeDocument(serializer, nestedDocumentValue); + Assert.Equal("JsonObject", withNestedValue["type"]!.GetValue()); + Assert.Equal(1, withNestedValue["value"]!["a"]!.GetValue()); + } + + [Fact(DisplayName = "LookupSerializer returns the JsonNode converter for JsonObject, JsonArray, and JsonValue")] + public void LookupSerializer_ConcreteJsonNodeTypes_UseJsonNodeConverter() + { + Assert.IsAssignableFrom>(BsonSerializer.LookupSerializer(typeof(JsonObject))); + Assert.IsAssignableFrom>(BsonSerializer.LookupSerializer(typeof(JsonArray))); + Assert.IsAssignableFrom>(BsonSerializer.LookupSerializer(typeof(JsonValue))); + Assert.IsAssignableFrom(BsonSerializer.LookupSerializer(typeof(JsonNode))); + } + + private static void AssertJsonValueRoundTrip(JsonValue original) + { + var restored = RoundTrip(new JsonNodeBsonConverter(), original); + Assert.Equal(original.ToJsonString(), restored.ToJsonString()); + } + + private static T DeserializeDocument(IBsonSerializer serializer, BsonDocument document) + { + using var reader = new BsonDocumentReader(document); + return serializer.Deserialize(BsonDeserializationContext.CreateRoot(reader)); + } + + private static T RoundTrip(IBsonSerializer serializer, T value) where T : JsonNode + { + var document = new BsonDocument(); + using (var writer = new BsonDocumentWriter(document)) + serializer.Serialize(BsonSerializationContext.CreateRoot(writer), value); + + using var reader = new BsonDocumentReader(document); + return serializer.Deserialize(BsonDeserializationContext.CreateRoot(reader)); + } + + private static object RoundTripObject(PolymorphicSerializer serializer, object value) + { + var document = new BsonDocument(); + using (var writer = new BsonDocumentWriter(document)) + serializer.Serialize(BsonSerializationContext.CreateRoot(writer), value); + + using var reader = new BsonDocumentReader(document); + return serializer.Deserialize(BsonDeserializationContext.CreateRoot(reader)); + } +} From a54d3b7381c489048971b4565ff567f598e91f85 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sat, 12 Sep 2026 00:32:01 -0700 Subject: [PATCH 003/169] Implement TryMarkInterruptedAsync on Mongo + Dapper WorkflowInstanceStore (#187) Companion to elsa-core#8059: mark Interrupted only while Status is still non-terminal so DrainOrchestrator cannot clobber a Finished commit. Fixes https://github.com/elsa-workflows/elsa-extensions/issues/187 Co-authored-by: Cursor Agent --- Elsa.Extensions.sln | 15 +++ .../Abstractions/SqlDialectBase.cs | 8 ++ .../Contracts/ISqlDialect.cs | 9 ++ .../ParameterizedQueryBuilderExtensions.cs | 21 +++++ .../Stores/DapperWorkflowInstanceStore.cs | 21 +++++ .../Elsa.Persistence.Dapper/Services/Store.cs | 14 +++ .../Management/WorkflowInstanceStore.cs | 18 ++++ .../Management/WorkflowInstanceStore.cs | 16 ++++ .../Elsa.Dapper.UnitTests.csproj | 11 +++ .../TryMarkInterruptedQueryTests.cs | 35 +++++++ .../MongoWorkflowInstanceStoreTests.cs | 92 +++++++++++++++++++ 11 files changed, 260 insertions(+) create mode 100644 test/modules/persistence/Elsa.Dapper.UnitTests/Elsa.Dapper.UnitTests.csproj create mode 100644 test/modules/persistence/Elsa.Dapper.UnitTests/TryMarkInterruptedQueryTests.cs create mode 100644 test/modules/persistence/Elsa.MongoDb.UnitTests/MongoWorkflowInstanceStoreTests.cs diff --git a/Elsa.Extensions.sln b/Elsa.Extensions.sln index 0b711b6356..a57e4df8cf 100644 --- a/Elsa.Extensions.sln +++ b/Elsa.Extensions.sln @@ -236,6 +236,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "slack", "slack", "{04087802 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.MongoDb.UnitTests", "test\modules\persistence\Elsa.MongoDb.UnitTests\Elsa.MongoDb.UnitTests.csproj", "{64AB629A-BE06-455C-98DF-C961623928BB}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Dapper.UnitTests", "test\modules\persistence\Elsa.Dapper.UnitTests\Elsa.Dapper.UnitTests.csproj", "{8F3A2C1D-6B47-4E90-9C15-2A7D4E8B1F03}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Slack.Tests", "test\modules\slack\Elsa.Slack.Tests\Elsa.Slack.Tests.csproj", "{3DD78303-DF05-4FCA-8DC7-B6503B182264}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "diagnostics", "diagnostics", "{8D0EC628-350F-47FC-8B36-DD98B4B7CC93}" @@ -1136,6 +1138,18 @@ Global {64AB629A-BE06-455C-98DF-C961623928BB}.Release|x64.Build.0 = Release|Any CPU {64AB629A-BE06-455C-98DF-C961623928BB}.Release|x86.ActiveCfg = Release|Any CPU {64AB629A-BE06-455C-98DF-C961623928BB}.Release|x86.Build.0 = Release|Any CPU + {8F3A2C1D-6B47-4E90-9C15-2A7D4E8B1F03}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8F3A2C1D-6B47-4E90-9C15-2A7D4E8B1F03}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8F3A2C1D-6B47-4E90-9C15-2A7D4E8B1F03}.Debug|x64.ActiveCfg = Debug|Any CPU + {8F3A2C1D-6B47-4E90-9C15-2A7D4E8B1F03}.Debug|x64.Build.0 = Debug|Any CPU + {8F3A2C1D-6B47-4E90-9C15-2A7D4E8B1F03}.Debug|x86.ActiveCfg = Debug|Any CPU + {8F3A2C1D-6B47-4E90-9C15-2A7D4E8B1F03}.Debug|x86.Build.0 = Debug|Any CPU + {8F3A2C1D-6B47-4E90-9C15-2A7D4E8B1F03}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8F3A2C1D-6B47-4E90-9C15-2A7D4E8B1F03}.Release|Any CPU.Build.0 = Release|Any CPU + {8F3A2C1D-6B47-4E90-9C15-2A7D4E8B1F03}.Release|x64.ActiveCfg = Release|Any CPU + {8F3A2C1D-6B47-4E90-9C15-2A7D4E8B1F03}.Release|x64.Build.0 = Release|Any CPU + {8F3A2C1D-6B47-4E90-9C15-2A7D4E8B1F03}.Release|x86.ActiveCfg = Release|Any CPU + {8F3A2C1D-6B47-4E90-9C15-2A7D4E8B1F03}.Release|x86.Build.0 = Release|Any CPU {3DD78303-DF05-4FCA-8DC7-B6503B182264}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3DD78303-DF05-4FCA-8DC7-B6503B182264}.Debug|Any CPU.Build.0 = Debug|Any CPU {3DD78303-DF05-4FCA-8DC7-B6503B182264}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -1530,6 +1544,7 @@ Global {273F8587-516E-41F3-93B9-D2F8FDC188A4} = {3DDE6F89-531C-47F8-9CD7-7A4E6984FA48} {04087802-CE19-4700-A4A9-536D7537F927} = {3DDE6F89-531C-47F8-9CD7-7A4E6984FA48} {64AB629A-BE06-455C-98DF-C961623928BB} = {273F8587-516E-41F3-93B9-D2F8FDC188A4} + {8F3A2C1D-6B47-4E90-9C15-2A7D4E8B1F03} = {273F8587-516E-41F3-93B9-D2F8FDC188A4} {3DD78303-DF05-4FCA-8DC7-B6503B182264} = {04087802-CE19-4700-A4A9-536D7537F927} {8D0EC628-350F-47FC-8B36-DD98B4B7CC93} = {30CF0330-4B09-4784-B499-46BED303810B} {28D04FA3-4DCC-4137-8ED4-9F6F1A815909} = {8D0EC628-350F-47FC-8B36-DD98B4B7CC93} diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Abstractions/SqlDialectBase.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Abstractions/SqlDialectBase.cs index e06b72ef1c..0a7378ee6c 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Abstractions/SqlDialectBase.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Abstractions/SqlDialectBase.cs @@ -92,6 +92,14 @@ public string Update(string table, string primaryKeyField, string[] fields, Func return $"UPDATE {table} SET {fieldList} WHERE {primaryKeyField} = @{getParamName(primaryKeyField)};"; } + /// + public string Update(string table, string[] fields, Func? getParamName = null) + { + getParamName ??= x => x; + var fieldList = string.Join(", ", fields.Select(x => $"{x} = @{getParamName(x)}")); + return $"UPDATE {table} SET {fieldList} WHERE 1=1"; + } + /// public virtual string Upsert(string table, string primaryKeyField, string[] fields, Func? getParamName = null) { diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Contracts/ISqlDialect.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Contracts/ISqlDialect.cs index a2c2b9af3c..6912440ef9 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Contracts/ISqlDialect.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Contracts/ISqlDialect.cs @@ -123,6 +123,15 @@ public interface ISqlDialect /// The query. string Update(string table, string primaryKeyField, string[] fields, Func? getParamName = null); + /// + /// Builds an UPDATE query with a WHERE 1=1 clause so additional filters can be appended. + /// + /// The table. + /// The fields to update. + /// An optional function to get the parameter name. + /// The query. + string Update(string table, string[] fields, Func? getParamName = null); + /// /// Builds an UPSERT query. /// diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs index 5f18e70a8a..776a04cc2f 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs @@ -461,6 +461,27 @@ public static ParameterizedQuery Update(this ParameterizedQuery query, string ta return query; } + /// + /// Begins an UPDATE query with a WHERE 1=1 clause so additional filters can be appended. + /// + public static ParameterizedQuery Update(this ParameterizedQuery query, string table, object record, string[] fields, Func? getParameterName = null) + { + getParameterName ??= x => x; + query.Sql.AppendLine(query.Dialect.Update(table, fields, getParameterName)); + + var recordType = record.GetType(); + foreach (var field in fields) + { + var prop = recordType.GetProperty(field)!; + var propType = prop.PropertyType; + var value = prop.GetValue(record); + var dbType = value == null ? GetDbType(propType) : null; + query.Parameters.Add($"@{getParameterName(field)}", value, dbType); + } + + return query; + } + private static DbType? GetDbType(Type type) { if (type == typeof(byte[])) return DbType.Binary; diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowInstanceStore.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowInstanceStore.cs index 6709b4f420..87d9bf132a 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowInstanceStore.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowInstanceStore.cs @@ -170,6 +170,27 @@ public async Task UpdateUpdatedTimestampAsync(string workflowInstanceId, DateTim await store.UpdateAsync(record, [x => x.UpdatedAt], cancellationToken); } + /// + public async ValueTask TryMarkInterruptedAsync(string workflowInstanceId, CancellationToken cancellationToken = default) + { + var record = new WorkflowInstanceRecord + { + Id = workflowInstanceId, + SubStatus = WorkflowSubStatus.Interrupted.ToString(), + IsExecuting = false + }; + + var updated = await store.UpdateAsync( + record, + [x => x.SubStatus, x => x.IsExecuting], + q => q + .Is(nameof(WorkflowInstanceRecord.Id), workflowInstanceId) + .IsNot(nameof(WorkflowInstanceRecord.Status), WorkflowStatus.Finished.ToString()), + cancellationToken); + + return updated > 0; + } + private void ApplyFilter(ParameterizedQuery query, WorkflowInstanceFilter filter) { query diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Services/Store.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Services/Store.cs index defce823a8..c254971ab9 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Services/Store.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Services/Store.cs @@ -443,6 +443,20 @@ public async Task UpdateAsync(T record, Expression>[] props, Can await query.ExecuteAsync(connection); } + /// + /// Updates the specified fields on records matching the filter. + /// + /// The number of records updated. + public async Task UpdateAsync(T record, Expression>[] props, Action filter, CancellationToken cancellationToken = default) + { + using var connection = dbConnectionProvider.GetConnection(); + var fields = props.Select(x => x.GetPropertyName()).ToArray(); + var query = dbConnectionProvider.CreateQuery().Update(TableName, record, fields); + ApplyTenantFilter(query); + filter(query); + return await query.ExecuteAsync(connection); + } + /// /// Deletes all records matching the specified query. /// diff --git a/src/modules/persistence/Elsa.Persistence.Elasticsearch/Modules/Management/WorkflowInstanceStore.cs b/src/modules/persistence/Elsa.Persistence.Elasticsearch/Modules/Management/WorkflowInstanceStore.cs index 261cc28618..d919b977be 100644 --- a/src/modules/persistence/Elsa.Persistence.Elasticsearch/Modules/Management/WorkflowInstanceStore.cs +++ b/src/modules/persistence/Elsa.Persistence.Elasticsearch/Modules/Management/WorkflowInstanceStore.cs @@ -4,6 +4,7 @@ using Elsa.Common.Models; using Elsa.Persistence.Elasticsearch.Common; using Elsa.Extensions; +using Elsa.Workflows; using Elsa.Workflows.Management; using Elsa.Workflows.Management.Entities; using Elsa.Workflows.Management.Filters; @@ -149,6 +150,23 @@ public Task UpdateUpdatedTimestampAsync(string workflowInstanceId, DateTimeOffse throw new NotImplementedException(); } + /// + public async ValueTask TryMarkInterruptedAsync(string workflowInstanceId, CancellationToken cancellationToken = default) + { + var instance = await FindAsync(new WorkflowInstanceFilter + { + Id = workflowInstanceId + }, cancellationToken); + + if (instance is null || instance.Status == WorkflowStatus.Finished) + return false; + + instance.SubStatus = WorkflowSubStatus.Interrupted; + instance.IsExecuting = false; + await _store.SaveAsync(instance, cancellationToken); + return true; + } + private static SearchRequestDescriptor Sort(SearchRequestDescriptor descriptor, WorkflowInstanceOrder order) { var sortDescriptor = new SortOptionsDescriptor(); diff --git a/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowInstanceStore.cs b/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowInstanceStore.cs index 55e9ede91b..1b65b9325d 100644 --- a/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowInstanceStore.cs +++ b/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowInstanceStore.cs @@ -3,6 +3,7 @@ using Elsa.Extensions; using Elsa.Persistence.MongoDb.Common; using Elsa.Persistence.MongoDb.Helpers; +using Elsa.Workflows; using Elsa.Workflows.Management; using Elsa.Workflows.Management.Entities; using Elsa.Workflows.Management.Filters; @@ -137,6 +138,21 @@ public async Task UpdateUpdatedTimestampAsync(string workflowInstanceId, DateTim logger.LogDebug("Failed to update the 'UpdatedAt' timestamp for workflow instance with ID '{WorkflowInstanceId}'. This means this workflow does not yet exist in the DB.", workflowInstanceId); } + /// + public async ValueTask TryMarkInterruptedAsync(string workflowInstanceId, CancellationToken cancellationToken = default) + { + var collection = mongoDbStore.GetCollection(); + var filter = Builders.Filter.And( + Builders.Filter.Eq(x => x.Id, workflowInstanceId), + Builders.Filter.Ne(x => x.Status, WorkflowStatus.Finished)); + var update = Builders.Update + .Set(x => x.SubStatus, WorkflowSubStatus.Interrupted) + .Set(x => x.IsExecuting, false); + + var result = await collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken); + return result.MatchedCount > 0; + } + /// public async ValueTask SaveAsync(WorkflowInstance instance, CancellationToken cancellationToken = default) { diff --git a/test/modules/persistence/Elsa.Dapper.UnitTests/Elsa.Dapper.UnitTests.csproj b/test/modules/persistence/Elsa.Dapper.UnitTests/Elsa.Dapper.UnitTests.csproj new file mode 100644 index 0000000000..8ee1adaa28 --- /dev/null +++ b/test/modules/persistence/Elsa.Dapper.UnitTests/Elsa.Dapper.UnitTests.csproj @@ -0,0 +1,11 @@ + + + + net10.0 + + + + + + + diff --git a/test/modules/persistence/Elsa.Dapper.UnitTests/TryMarkInterruptedQueryTests.cs b/test/modules/persistence/Elsa.Dapper.UnitTests/TryMarkInterruptedQueryTests.cs new file mode 100644 index 0000000000..56555fcc83 --- /dev/null +++ b/test/modules/persistence/Elsa.Dapper.UnitTests/TryMarkInterruptedQueryTests.cs @@ -0,0 +1,35 @@ +using Elsa.Persistence.Dapper.Dialects; +using Elsa.Persistence.Dapper.Extensions; +using Elsa.Persistence.Dapper.Models; +using Elsa.Workflows; + +namespace Elsa.Dapper.UnitTests; + +public class TryMarkInterruptedQueryTests +{ + [Fact(DisplayName = "Conditional interrupt update targets non-terminal rows only")] + public void UpdateQuery_IncludesIdAndNonFinishedStatus() + { + var record = new + { + Id = "running-1", + SubStatus = WorkflowSubStatus.Interrupted.ToString(), + IsExecuting = false + }; + + var query = new ParameterizedQuery(new SqliteDialect()) + .Update("WorkflowInstances", record, ["SubStatus", "IsExecuting"]) + .Is("Id", record.Id) + .IsNot("Status", WorkflowStatus.Finished.ToString()); + + var sql = query.Sql.ToString(); + + Assert.Contains("UPDATE WorkflowInstances SET SubStatus = @SubStatus, IsExecuting = @IsExecuting WHERE 1=1", sql, StringComparison.Ordinal); + Assert.Contains("and Id = @Id", sql, StringComparison.Ordinal); + Assert.Contains("and not Status = @Status", sql, StringComparison.Ordinal); + Assert.Equal(WorkflowSubStatus.Interrupted.ToString(), query.Parameters.Get("SubStatus")); + Assert.False(query.Parameters.Get("IsExecuting")); + Assert.Equal("running-1", query.Parameters.Get("Id")); + Assert.Equal(WorkflowStatus.Finished.ToString(), query.Parameters.Get("Status")); + } +} diff --git a/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoWorkflowInstanceStoreTests.cs b/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoWorkflowInstanceStoreTests.cs new file mode 100644 index 0000000000..908d082bac --- /dev/null +++ b/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoWorkflowInstanceStoreTests.cs @@ -0,0 +1,92 @@ +using Elsa.Common.Multitenancy; +using Elsa.Persistence.MongoDb.Common; +using Elsa.Persistence.MongoDb.Modules.Management; +using Elsa.Workflows; +using Elsa.Workflows.Management.Entities; +using Microsoft.Extensions.Logging; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Driver; +using NSubstitute; + +namespace Elsa.MongoDb.UnitTests; + +public class MongoWorkflowInstanceStoreTests +{ + [Fact(DisplayName = "TryMarkInterruptedAsync updates only when Status is not Finished")] + public async Task TryMarkInterruptedAsync_UpdatesNonTerminalInstance() + { + var (store, collection) = CreateStore(matchedCount: 1); + + var marked = await store.TryMarkInterruptedAsync("running-1"); + + Assert.True(marked); + await collection.Received(1).UpdateOneAsync( + Arg.Is>(filter => FilterMentionsIdAndNonFinished(filter, "running-1")), + Arg.Is>(update => UpdateSetsInterruptedMarkers(update)), + Arg.Any(), + Arg.Any()); + } + + [Fact(DisplayName = "TryMarkInterruptedAsync returns false when no non-terminal instance matches")] + public async Task TryMarkInterruptedAsync_ReturnsFalseWhenMissingOrFinished() + { + var (store, _) = CreateStore(matchedCount: 0); + + var marked = await store.TryMarkInterruptedAsync("finished-1"); + + Assert.False(marked); + } + + private static (MongoWorkflowInstanceStore Store, IMongoCollection Collection) CreateStore(long matchedCount) + { + var collection = Substitute.For>(); + var updateResult = Substitute.For(); + updateResult.IsAcknowledged.Returns(true); + updateResult.MatchedCount.Returns(matchedCount); + updateResult.ModifiedCount.Returns(matchedCount); + + collection.UpdateOneAsync( + Arg.Any>(), + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(updateResult); + + var mongoDbStore = new MongoDbStore(collection, Substitute.For()); + var store = new MongoWorkflowInstanceStore(mongoDbStore, Substitute.For>()); + return (store, collection); + } + + private static bool FilterMentionsIdAndNonFinished(FilterDefinition filter, string id) + { + var rendered = Render(filter); + return rendered.Contains(id, StringComparison.Ordinal) + && rendered.Contains("Status", StringComparison.Ordinal) + && rendered.Contains("$ne", StringComparison.Ordinal) + && rendered.Contains(((int)WorkflowStatus.Finished).ToString(), StringComparison.Ordinal); + } + + private static bool UpdateSetsInterruptedMarkers(UpdateDefinition update) + { + var rendered = Render(update); + return rendered.Contains("$set", StringComparison.Ordinal) + && rendered.Contains("SubStatus", StringComparison.Ordinal) + && rendered.Contains(((int)WorkflowSubStatus.Interrupted).ToString(), StringComparison.Ordinal) + && rendered.Contains("IsExecuting", StringComparison.Ordinal); + } + + private static string Render(FilterDefinition filter) + { + var serializer = BsonSerializer.LookupSerializer(); + var rendered = filter.Render(new RenderArgs(serializer, BsonSerializer.SerializerRegistry)); + return rendered.ToJson(); + } + + private static string Render(UpdateDefinition update) + { + var serializer = BsonSerializer.LookupSerializer(); + var rendered = update.Render(new RenderArgs(serializer, BsonSerializer.SerializerRegistry)); + return rendered.ToJson(); + } +} From 01bf9ad70d0399afadae9609fe820703d3de290d Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sat, 12 Sep 2026 03:27:20 -0700 Subject: [PATCH 004/169] fix(dapper): honor BeforeLastUpdated in the workflow instance store (#183) DapperWorkflowInstanceStore.ApplyFilter never applied WorkflowInstanceFilter.BeforeLastUpdated. The property was accepted and then silently dropped from the generated SQL, so callers got unfiltered results with no error. RestartInterruptedWorkflowsTask relies on that property to distinguish a genuinely interrupted workflow from one that is simply executing right now. On the Dapper provider its inactivity guard was therefore a no-op: the query degenerated to IsExecuting = 1, and any workflow that happened to be mid-execution when the task ticked was restarted from the beginning. The query builder had no inequality support at all (only Is, IsNot, IsNull, In, NotIn and StartsWith), which is why the property could not be expressed. Add a LessThan clause and wire it up. (cherry picked from commit 28fcb585051340f682e54ecfb239e6c0ad49a59a) Co-authored-by: Ronald Kroon --- .../Elsa.Persistence.Dapper.csproj | 4 + .../ParameterizedQueryBuilderExtensions.cs | 16 ++ .../Stores/DapperWorkflowInstanceStore.cs | 1 + .../DapperWorkflowInstanceStoreTests.cs | 148 ++++++++++++++++++ .../Elsa.Dapper.UnitTests.csproj | 4 + ...arameterizedQueryBuilderExtensionsTests.cs | 39 +++++ 6 files changed, 212 insertions(+) create mode 100644 test/modules/persistence/Elsa.Dapper.UnitTests/DapperWorkflowInstanceStoreTests.cs create mode 100644 test/modules/persistence/Elsa.Dapper.UnitTests/ParameterizedQueryBuilderExtensionsTests.cs diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Elsa.Persistence.Dapper.csproj b/src/modules/persistence/Elsa.Persistence.Dapper/Elsa.Persistence.Dapper.csproj index 2fce27238b..cf534340f6 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Elsa.Persistence.Dapper.csproj +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Elsa.Persistence.Dapper.csproj @@ -11,6 +11,10 @@ + + + + diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs index 776a04cc2f..310eaa25d1 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs @@ -165,6 +165,22 @@ public static ParameterizedQuery IsNotNull(this ParameterizedQuery query, string return query; } + /// + /// Appends an AND clause matching values strictly less than the specified value, if the value is not null. + /// + /// The query. + /// The field. + /// The value. + public static ParameterizedQuery LessThan(this ParameterizedQuery query, string field, object? value) + { + if (value == null) return query; + + query.Sql.AppendLine($"and {field} < @{field}"); + query.Parameters.Add($"@{field}", value); + + return query; + } + /// /// Appends a search term for workflow definitions to the search. /// diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowInstanceStore.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowInstanceStore.cs index 87d9bf132a..28ef88d95f 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowInstanceStore.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowInstanceStore.cs @@ -209,6 +209,7 @@ private void ApplyFilter(ParameterizedQuery query, WorkflowInstanceFilter filter .In(nameof(WorkflowInstance.Status), filter.WorkflowStatuses?.Select(x => x.ToString())) .In(nameof(WorkflowInstance.SubStatus), filter.WorkflowSubStatuses?.Select(x => x.ToString())) .Is(nameof(WorkflowInstance.IsExecuting), filter.IsExecuting) + .LessThan(nameof(WorkflowInstance.UpdatedAt), filter.BeforeLastUpdated) .AndWorkflowInstanceSearchTerm(filter.SearchTerm); } diff --git a/test/modules/persistence/Elsa.Dapper.UnitTests/DapperWorkflowInstanceStoreTests.cs b/test/modules/persistence/Elsa.Dapper.UnitTests/DapperWorkflowInstanceStoreTests.cs new file mode 100644 index 0000000000..ea5f0e7d92 --- /dev/null +++ b/test/modules/persistence/Elsa.Dapper.UnitTests/DapperWorkflowInstanceStoreTests.cs @@ -0,0 +1,148 @@ +using Dapper; +using Elsa.Common.Multitenancy; +using Elsa.Persistence.Dapper.Modules.Management.Records; +using Elsa.Persistence.Dapper.Modules.Management.Stores; +using Elsa.Persistence.Dapper.Services; +using Elsa.Workflows; +using Elsa.Workflows.Management.Filters; +using Microsoft.Data.Sqlite; +using NSubstitute; + +namespace Elsa.Dapper.UnitTests; + +public sealed class DapperWorkflowInstanceStoreTests : IDisposable +{ + private static readonly DateTimeOffset Cutoff = new(2026, 1, 1, 12, 5, 0, TimeSpan.Zero); + + private readonly string _databasePath = Path.Combine(Path.GetTempPath(), $"elsa-dapper-instances-{Guid.NewGuid():N}.db"); + private readonly DapperWorkflowInstanceStore _store; + private readonly TestTenantAccessor _tenantAccessor = new(); + + public DapperWorkflowInstanceStoreTests() + { + var connectionString = new SqliteConnectionStringBuilder { DataSource = _databasePath, Pooling = false }.ToString(); + var connectionProvider = new SqliteDbConnectionProvider(connectionString); + + using var connection = new SqliteConnection(connectionString); + connection.Open(); + connection.Execute(""" + create table WorkflowInstances ( + Id text not null primary key, + TenantId text null, + DefinitionId text not null, + DefinitionVersionId text not null, + Version integer not null, + WorkflowState text not null, + Status text not null, + SubStatus text not null, + IsExecuting integer not null, + CorrelationId text null, + Name text null, + IncidentCount integer not null, + IsSystem integer not null, + CreatedAt text not null, + UpdatedAt text null, + FinishedAt text null + ); + """); + + // Seeded through parameters rather than literals so the driver writes the timestamps in + // exactly the format it later binds them in for comparison. + Insert(connection, "stale-executing", Cutoff.AddMinutes(-5), isExecuting: true); + Insert(connection, "stale-idle", Cutoff.AddMinutes(-1), isExecuting: false); + Insert(connection, "at-cutoff", Cutoff, isExecuting: true); + Insert(connection, "fresh-executing", Cutoff.AddMinutes(4), isExecuting: true); + + var store = new Store(connectionProvider, _tenantAccessor, "WorkflowInstances"); + _store = new DapperWorkflowInstanceStore(store, Substitute.For()); + } + + [Fact] + public async Task FindManyIdsAsync_WithBeforeLastUpdated_ExcludesInstancesUpdatedAtOrAfterTheCutoff() + { + using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + + var ids = await _store.FindManyIdsAsync(new WorkflowInstanceFilter { BeforeLastUpdated = Cutoff }); + + Assert.Equal(["stale-executing", "stale-idle"], ids.Order()); + } + + /// + /// Mirrors the filter used by RestartInterruptedWorkflowsTask. An instance that is executing right + /// now must not be reported as interrupted, or it gets restarted from the beginning mid-flight. + /// + [Fact] + public async Task FindManyIdsAsync_WithBeforeLastUpdatedAndIsExecuting_ExcludesInstancesThatAreStillActive() + { + using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + + var ids = await _store.FindManyIdsAsync(new WorkflowInstanceFilter + { + IsExecuting = true, + BeforeLastUpdated = Cutoff + }); + + Assert.Equal(["stale-executing"], ids); + } + + [Fact] + public async Task FindManyIdsAsync_WithoutBeforeLastUpdated_ReturnsAllInstances() + { + using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + + var ids = await _store.FindManyIdsAsync(new WorkflowInstanceFilter()); + + Assert.Equal(["at-cutoff", "fresh-executing", "stale-executing", "stale-idle"], ids.Order()); + } + + [Fact] + public async Task CountAsync_WithBeforeLastUpdated_CountsOnlyInstancesUpdatedBeforeTheCutoff() + { + using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + + var count = await _store.CountAsync(new WorkflowInstanceFilter { BeforeLastUpdated = Cutoff }); + + Assert.Equal(2, count); + } + + public void Dispose() + { + File.Delete(_databasePath); + } + + private static void Insert(SqliteConnection connection, string id, DateTimeOffset updatedAt, bool isExecuting) + { + connection.Execute( + """ + insert into WorkflowInstances + (Id, TenantId, DefinitionId, DefinitionVersionId, Version, WorkflowState, Status, SubStatus, IsExecuting, IncidentCount, IsSystem, CreatedAt, UpdatedAt) + values + (@Id, 'tenant-a', 'definition-1', 'definition-1:1', 1, '{}', 'Running', 'Executing', @IsExecuting, 0, 0, @CreatedAt, @UpdatedAt); + """, + new + { + Id = id, + IsExecuting = isExecuting, + CreatedAt = updatedAt, + UpdatedAt = updatedAt + }); + } + + private sealed class TestTenantAccessor : ITenantAccessor + { + public string TenantId => Tenant?.Id ?? Elsa.Common.Multitenancy.Tenant.DefaultTenantId; + public Tenant? Tenant { get; private set; } + + public IDisposable PushContext(Tenant? tenant) + { + var previousTenant = Tenant; + Tenant = tenant; + return new Restore(() => Tenant = previousTenant); + } + + private sealed class Restore(Action restore) : IDisposable + { + public void Dispose() => restore(); + } + } +} diff --git a/test/modules/persistence/Elsa.Dapper.UnitTests/Elsa.Dapper.UnitTests.csproj b/test/modules/persistence/Elsa.Dapper.UnitTests/Elsa.Dapper.UnitTests.csproj index 8ee1adaa28..c13424f6a2 100644 --- a/test/modules/persistence/Elsa.Dapper.UnitTests/Elsa.Dapper.UnitTests.csproj +++ b/test/modules/persistence/Elsa.Dapper.UnitTests/Elsa.Dapper.UnitTests.csproj @@ -4,6 +4,10 @@ net10.0 + + + + diff --git a/test/modules/persistence/Elsa.Dapper.UnitTests/ParameterizedQueryBuilderExtensionsTests.cs b/test/modules/persistence/Elsa.Dapper.UnitTests/ParameterizedQueryBuilderExtensionsTests.cs new file mode 100644 index 0000000000..b21f8fc32a --- /dev/null +++ b/test/modules/persistence/Elsa.Dapper.UnitTests/ParameterizedQueryBuilderExtensionsTests.cs @@ -0,0 +1,39 @@ +using Elsa.Persistence.Dapper.Dialects; +using Elsa.Persistence.Dapper.Extensions; +using Elsa.Persistence.Dapper.Models; + +namespace Elsa.Dapper.UnitTests; + +public class ParameterizedQueryBuilderExtensionsTests +{ + [Fact(DisplayName = "LessThan appends an exclusive comparison and binds @{field}")] + public void LessThan_WithValue_AppendsExclusiveComparison() + { + var cutoff = new DateTimeOffset(2026, 1, 1, 12, 5, 0, TimeSpan.Zero); + var query = new ParameterizedQuery(new SqliteDialect()) + .From("WorkflowInstances") + .LessThan("UpdatedAt", cutoff); + + var sql = query.Sql.ToString(); + + Assert.Contains("and UpdatedAt < @UpdatedAt", sql, StringComparison.Ordinal); + Assert.DoesNotContain("<=", sql, StringComparison.Ordinal); + Assert.Equal(cutoff, query.Parameters.Get("UpdatedAt")); + } + + [Fact(DisplayName = "LessThan is a no-op when the value is null")] + public void LessThan_WithNullValue_PreservesOtherFilters() + { + var query = new ParameterizedQuery(new SqliteDialect()) + .From("WorkflowInstances") + .Is("IsExecuting", true) + .LessThan("UpdatedAt", null); + + var sql = query.Sql.ToString(); + + Assert.Contains("and IsExecuting = @IsExecuting", sql, StringComparison.Ordinal); + Assert.DoesNotContain("UpdatedAt", sql, StringComparison.Ordinal); + Assert.DoesNotContain("UpdatedAt", query.Parameters.ParameterNames); + Assert.True(query.Parameters.Get("IsExecuting")); + } +} From e6d5989cc96dd50bc7ddf67161ecac14f915d8f1 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sat, 12 Sep 2026 03:54:51 -0700 Subject: [PATCH 005/169] fix: AddOpenIdConnectAuth no longer throws on provider registration (#1016) AddStudioAuthenticationProviderRegistration used TryAddEnumerable with an instance descriptor whose implementation type equals the service type. That throws ArgumentException (TryAddIndistinguishableTypeToEnumerable) on the first call, so AddOpenIdConnectAuth crashed on Studio 3.8.0. Register the marker with AddSingleton, matching other auth modules, so IEnumerable still works. Fixes #1016 Co-authored-by: Cursor Agent --- .../Extensions/ServiceCollectionExtensions.cs | 6 +- ...AuthenticationProviderRegistrationTests.cs | 63 +++++++++++++++++++ ...Studio.ExternalAuthentication.Tests.csproj | 1 + 3 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 src/modules/Elsa.Studio.ExternalAuthentication.Tests/Compatibility/StudioAuthenticationProviderRegistrationTests.cs diff --git a/src/modules/Elsa.Studio.Authentication.Abstractions/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.Studio.Authentication.Abstractions/Extensions/ServiceCollectionExtensions.cs index 84fa7e93cf..fa5bf97ec2 100644 --- a/src/modules/Elsa.Studio.Authentication.Abstractions/Extensions/ServiceCollectionExtensions.cs +++ b/src/modules/Elsa.Studio.Authentication.Abstractions/Extensions/ServiceCollectionExtensions.cs @@ -30,8 +30,10 @@ public static IServiceCollection AddStudioAuthenticationProviderRegistration( this IServiceCollection services, StudioAuthenticationProvider provider) { - services.TryAddEnumerable( - ServiceDescriptor.Singleton(new StudioAuthenticationProviderRegistration(provider))); + // Instance descriptors have implementation type == service type. TryAddEnumerable + // rejects that shape on the first call (TryAddIndistinguishableTypeToEnumerable). + // AddSingleton still surfaces the marker through IEnumerable for validation. + services.AddSingleton(new StudioAuthenticationProviderRegistration(provider)); return services; } } diff --git a/src/modules/Elsa.Studio.ExternalAuthentication.Tests/Compatibility/StudioAuthenticationProviderRegistrationTests.cs b/src/modules/Elsa.Studio.ExternalAuthentication.Tests/Compatibility/StudioAuthenticationProviderRegistrationTests.cs new file mode 100644 index 0000000000..93a44f8e95 --- /dev/null +++ b/src/modules/Elsa.Studio.ExternalAuthentication.Tests/Compatibility/StudioAuthenticationProviderRegistrationTests.cs @@ -0,0 +1,63 @@ +using Elsa.Studio.Authentication.Abstractions.Models; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using ServerOpenIdConnect = Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Extensions.ServiceCollectionExtensions; +using WasmOpenIdConnect = Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm.Extensions.ServiceCollectionExtensions; + +namespace Elsa.Studio.ExternalAuthentication.Tests.Compatibility; + +public class StudioAuthenticationProviderRegistrationTests +{ + [Fact] + public void AddStudioAuthenticationProviderRegistration_DoesNotThrowOnFirstCall() + { + var services = new ServiceCollection(); + + var exception = Record.Exception(() => + services.AddStudioAuthenticationProviderRegistration(StudioAuthenticationProvider.OpenIdConnect)); + + Assert.Null(exception); + using var provider = services.BuildServiceProvider(); + Assert.Equal( + StudioAuthenticationProvider.OpenIdConnect, + Assert.Single(provider.GetServices()).Provider); + } + + [Fact] + public void AddOpenIdConnectAuth_Server_DoesNotThrowOnRegistration() + { + var services = new ServiceCollection(); + + var exception = Record.Exception(() => + ServerOpenIdConnect.AddOpenIdConnectAuth(services, options => + { + options.Authority = "https://login.microsoftonline.com/{tenant}/v2.0"; + options.ClientId = "any-client-id"; + })); + + Assert.Null(exception); + using var provider = services.BuildServiceProvider(); + Assert.Contains( + provider.GetServices(), + registration => registration.Provider == StudioAuthenticationProvider.OpenIdConnect); + } + + [Fact] + public void AddOpenIdConnectAuth_Wasm_DoesNotThrowOnRegistration() + { + var services = new ServiceCollection(); + + var exception = Record.Exception(() => + WasmOpenIdConnect.AddOpenIdConnectAuth(services, options => + { + options.Authority = "https://login.microsoftonline.com/{tenant}/v2.0"; + options.ClientId = "any-client-id"; + })); + + Assert.Null(exception); + using var provider = services.BuildServiceProvider(); + Assert.Contains( + provider.GetServices(), + registration => registration.Provider == StudioAuthenticationProvider.OpenIdConnect); + } +} diff --git a/src/modules/Elsa.Studio.ExternalAuthentication.Tests/Elsa.Studio.ExternalAuthentication.Tests.csproj b/src/modules/Elsa.Studio.ExternalAuthentication.Tests/Elsa.Studio.ExternalAuthentication.Tests.csproj index c018b54f52..bd66bce5a1 100644 --- a/src/modules/Elsa.Studio.ExternalAuthentication.Tests/Elsa.Studio.ExternalAuthentication.Tests.csproj +++ b/src/modules/Elsa.Studio.ExternalAuthentication.Tests/Elsa.Studio.ExternalAuthentication.Tests.csproj @@ -28,6 +28,7 @@ + From c085ee5630a77b818794f95fd226b7874a89b36a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 10:58:51 +0000 Subject: [PATCH 006/169] fix: accept static feature catalog names for dashboard modules RemoteFeatureProvider and DefaultFeatureService compared catalog FullName exactly to ShellFeatures CLR names. Static feature model advertises Elsa.{FeatureId} (e.g. Elsa.WorkflowRuntimeDashboard), so WorkflowRuntimeDashboard and StructuredLogs widgets never initialized even though the backend had the features installed. Match catalog entries by feature id as well as full name. Fixes https://github.com/elsa-workflows/elsa-studio/issues/1014 Co-authored-by: Sipke Schoorstra --- .../DefaultFeatureServiceTests.cs | 98 +++++++++++++++++++ .../Services/DefaultFeatureService.cs | 11 +-- .../Services/RemoteFeatureCatalog.cs | 40 ++++++++ .../Services/RemoteFeatureProvider.cs | 2 +- .../DashboardWidgetRegistrationTests.cs | 44 +++++++++ .../Operations/RemoteFeatureProviderTests.cs | 33 ++++++- 6 files changed, 218 insertions(+), 10 deletions(-) create mode 100644 src/framework/Elsa.Studio.Core.Tests/DefaultFeatureServiceTests.cs create mode 100644 src/framework/Elsa.Studio.Core/Services/RemoteFeatureCatalog.cs diff --git a/src/framework/Elsa.Studio.Core.Tests/DefaultFeatureServiceTests.cs b/src/framework/Elsa.Studio.Core.Tests/DefaultFeatureServiceTests.cs new file mode 100644 index 0000000000..aac9518a2c --- /dev/null +++ b/src/framework/Elsa.Studio.Core.Tests/DefaultFeatureServiceTests.cs @@ -0,0 +1,98 @@ +using Elsa.Api.Client.Resources.Features.Models; +using Elsa.Studio.Attributes; +using Elsa.Studio.Contracts; +using Elsa.Studio.Services; +using Xunit; + +namespace Elsa.Studio.Core.Tests; + +public class DefaultFeatureServiceTests +{ + [Fact] + public async Task InitializeFeatures_EnablesCompanionsAdvertisedByStaticCatalogNames() + { + var dashboard = new WorkflowRuntimeDashboardFeature(); + var structuredLogs = new StructuredLogsDashboardFeature(); + var service = new DefaultFeatureService( + [dashboard, structuredLogs], + new CatalogRemoteFeatureProvider( + new FeatureDescriptor { FullName = "Elsa.WorkflowRuntimeDashboard" }, + new FeatureDescriptor { FullName = "Elsa.StructuredLogsDashboard" })); + + await service.InitializeFeaturesAsync(); + + Assert.True(dashboard.Initialized); + Assert.True(structuredLogs.Initialized); + } + + [Fact] + public async Task InitializeFeatures_EnablesCompanionsAdvertisedByShellFeatureNames() + { + var dashboard = new WorkflowRuntimeDashboardFeature(); + var service = new DefaultFeatureService( + [dashboard], + new CatalogRemoteFeatureProvider( + new FeatureDescriptor { FullName = WorkflowRuntimeDashboardFeature.RemoteFeatureName })); + + await service.InitializeFeaturesAsync(); + + Assert.True(dashboard.Initialized); + } + + [Fact] + public async Task InitializeFeatures_SkipsCompanionsMissingFromCatalog() + { + var dashboard = new WorkflowRuntimeDashboardFeature(); + var service = new DefaultFeatureService( + [dashboard], + new CatalogRemoteFeatureProvider(new FeatureDescriptor { FullName = "Elsa.Secrets" })); + + await service.InitializeFeaturesAsync(); + + Assert.False(dashboard.Initialized); + } + + [Fact] + public async Task InitializeFeatures_AlwaysInitializesUngatedFeatures() + { + var local = new LocalFeature(); + var service = new DefaultFeatureService( + [local], + new CatalogRemoteFeatureProvider()); + + await service.InitializeFeaturesAsync(); + + Assert.True(local.Initialized); + } + + [RemoteFeature(RemoteFeatureName)] + private sealed class WorkflowRuntimeDashboardFeature : TrackingFeature + { + public const string RemoteFeatureName = "Elsa.Workflows.Runtime.Dashboard.ShellFeatures.WorkflowRuntimeDashboard"; + } + + [RemoteFeature("Elsa.Diagnostics.StructuredLogs.Dashboard.ShellFeatures.StructuredLogsDashboard")] + private sealed class StructuredLogsDashboardFeature : TrackingFeature; + + private sealed class LocalFeature : TrackingFeature; + + private abstract class TrackingFeature : IFeature + { + public bool Initialized { get; private set; } + + public ValueTask InitializeAsync(CancellationToken cancellationToken = default) + { + Initialized = true; + return ValueTask.CompletedTask; + } + } + + private sealed class CatalogRemoteFeatureProvider(params FeatureDescriptor[] features) : IRemoteFeatureProvider + { + public Task IsEnabledAsync(string featureName, CancellationToken cancellationToken = default) => + Task.FromResult(features.Any(feature => string.Equals(feature.FullName, featureName, StringComparison.Ordinal))); + + public Task> ListAsync(CancellationToken cancellationToken = default) => + Task.FromResult>(features); + } +} diff --git a/src/framework/Elsa.Studio.Core/Services/DefaultFeatureService.cs b/src/framework/Elsa.Studio.Core/Services/DefaultFeatureService.cs index 64d5481fdb..64549cd9b3 100644 --- a/src/framework/Elsa.Studio.Core/Services/DefaultFeatureService.cs +++ b/src/framework/Elsa.Studio.Core/Services/DefaultFeatureService.cs @@ -37,14 +37,9 @@ public async Task InitializeFeaturesAsync(CancellationToken cancellationToken = { var remoteFeatureName = feature.GetType().GetCustomAttribute()?.Name; - if (!string.IsNullOrWhiteSpace(remoteFeatureName)) - { - // Check if the remote feature is enabled. - var remoteFeatureIsEnabled = remoteFeatures.Any(x => x.FullName == remoteFeatureName); - - if (!remoteFeatureIsEnabled) - continue; - } + if (!string.IsNullOrWhiteSpace(remoteFeatureName) && + !RemoteFeatureCatalog.Contains(remoteFeatures, remoteFeatureName)) + continue; await feature.InitializeAsync(cancellationToken); } diff --git a/src/framework/Elsa.Studio.Core/Services/RemoteFeatureCatalog.cs b/src/framework/Elsa.Studio.Core/Services/RemoteFeatureCatalog.cs new file mode 100644 index 0000000000..f5630d955d --- /dev/null +++ b/src/framework/Elsa.Studio.Core/Services/RemoteFeatureCatalog.cs @@ -0,0 +1,40 @@ +using Elsa.Api.Client.Resources.Features.Models; + +namespace Elsa.Studio.Services; + +/// +/// Matches Studio remote feature names against the backend installed-feature catalog. +/// +/// +/// Dynamic/shell catalogs advertise the ShellFeatures CLR name +/// (Elsa.*.ShellFeatures.{FeatureId}). Static feature catalogs advertise the +/// CShells feature id as Elsa.{FeatureId} (for example +/// Elsa.WorkflowRuntimeDashboard). Studio modules request the ShellFeatures name, +/// so both catalog forms must resolve as the same installed feature. +/// +internal static class RemoteFeatureCatalog +{ + public static bool Contains(IEnumerable catalog, string featureName) => + catalog.Any(feature => Matches(feature, featureName)); + + public static bool Matches(FeatureDescriptor feature, string featureName) + { + if (string.Equals(feature.FullName, featureName, StringComparison.Ordinal)) + return true; + + var requestedId = GetFeatureId(featureName); + + if (!string.IsNullOrEmpty(feature.Name) && + string.Equals(feature.Name, requestedId, StringComparison.Ordinal)) + return true; + + return !string.IsNullOrEmpty(feature.FullName) && + string.Equals(GetFeatureId(feature.FullName), requestedId, StringComparison.Ordinal); + } + + private static string GetFeatureId(string featureName) + { + var lastDot = featureName.LastIndexOf('.'); + return lastDot < 0 ? featureName : featureName[(lastDot + 1)..]; + } +} diff --git a/src/framework/Elsa.Studio.Core/Services/RemoteFeatureProvider.cs b/src/framework/Elsa.Studio.Core/Services/RemoteFeatureProvider.cs index 7f1f374c50..96e00d0391 100644 --- a/src/framework/Elsa.Studio.Core/Services/RemoteFeatureProvider.cs +++ b/src/framework/Elsa.Studio.Core/Services/RemoteFeatureProvider.cs @@ -21,7 +21,7 @@ public class RemoteFeatureProvider( public async Task IsEnabledAsync(string featureName, CancellationToken cancellationToken = default) { var catalog = await GetCatalogAsync(cancellationToken); - return catalog.Any(feature => string.Equals(feature.FullName, featureName, StringComparison.Ordinal)); + return RemoteFeatureCatalog.Contains(catalog, featureName); } /// diff --git a/src/modules/Elsa.Studio.Dashboard.Tests/DashboardWidgetRegistrationTests.cs b/src/modules/Elsa.Studio.Dashboard.Tests/DashboardWidgetRegistrationTests.cs index 27eb7ca5ae..e823f4d462 100644 --- a/src/modules/Elsa.Studio.Dashboard.Tests/DashboardWidgetRegistrationTests.cs +++ b/src/modules/Elsa.Studio.Dashboard.Tests/DashboardWidgetRegistrationTests.cs @@ -1,3 +1,4 @@ +using Elsa.Api.Client.Resources.Features.Models; using Elsa.Studio.Attributes; using Elsa.Studio.Contracts; using Elsa.Studio.Dashboard.Extensions; @@ -8,6 +9,7 @@ using Elsa.Studio.Diagnostics.OpenTelemetry.Dashboard.UI.Dashboard; using Elsa.Studio.Diagnostics.StructuredLogs.Dashboard.Extensions; using Elsa.Studio.Diagnostics.StructuredLogs.Dashboard.UI.Dashboard; +using Elsa.Studio.Services; using Elsa.Studio.Workflows.Dashboard.Extensions; using Elsa.Studio.Workflows.Dashboard.Widgets; using Microsoft.AspNetCore.Components; @@ -123,6 +125,39 @@ public void DashboardCompanionFeatures_DeclareRemoteBackendFeatureNames() AssertRemoteFeatureName("Elsa.Diagnostics.OpenTelemetry.ShellFeatures.OpenTelemetry"); } + [Fact] + public async Task DefaultFeatureService_RegistersWidgetsWhenStaticCatalogAdvertisesShortNames() + { + var services = new ServiceCollection(); + + services + .AddDashboardModule() + .AddWorkflowsDashboardModule() + .AddStructuredLogsDashboardModule() + .AddConsoleLogsDashboardModule() + .AddOpenTelemetryDashboardModule(); + + var serviceProvider = services.BuildServiceProvider(); + var features = serviceProvider.GetRequiredService>().Where(x => x.GetType().Namespace?.EndsWith(".Dashboard", StringComparison.Ordinal) == true).ToList(); + var registry = serviceProvider.GetRequiredService(); + var featureService = new DefaultFeatureService( + features, + new CatalogRemoteFeatureProvider( + new FeatureDescriptor { FullName = "Elsa.WorkflowRuntimeDashboard" }, + new FeatureDescriptor { FullName = "Elsa.StructuredLogsDashboard" }, + new FeatureDescriptor { FullName = "Elsa.ConsoleLogsDashboard" }, + new FeatureDescriptor { FullName = "Elsa.OpenTelemetry" })); + + await featureService.InitializeFeaturesAsync(); + + var descriptors = registry.List(); + + AssertDescriptor(descriptors, "dashboard.workflow.metrics", DashboardWidgetZones.Metrics, 100, "WorkflowInstances"); + AssertDescriptor(descriptors, "diagnostics.structured-logs", DashboardWidgetZones.DiagnosticsStatus, 100, "Diagnostics.StructuredLogs"); + AssertDescriptor(descriptors, "diagnostics.console-logs", DashboardWidgetZones.DiagnosticsStatus, 200, "Diagnostics.ConsoleLogs"); + AssertDescriptor(descriptors, "diagnostics.open-telemetry", DashboardWidgetZones.DiagnosticsStatus, 300, "OpenTelemetry.StorageDiagnostics"); + } + private sealed class TestWidget : IComponent { public void Attach(RenderHandle renderHandle) @@ -154,6 +189,15 @@ private static void AssertRemoteFeatureName(string expectedName) Assert.Equal(expectedName, attribute.Name); } + private sealed class CatalogRemoteFeatureProvider(params FeatureDescriptor[] features) : IRemoteFeatureProvider + { + public Task IsEnabledAsync(string featureName, CancellationToken cancellationToken = default) => + Task.FromResult(features.Any(feature => string.Equals(feature.FullName, featureName, StringComparison.Ordinal))); + + public Task> ListAsync(CancellationToken cancellationToken = default) => + Task.FromResult>(features); + } + private static DirectoryInfo FindRepositoryRoot() { var directory = new DirectoryInfo(AppContext.BaseDirectory); diff --git a/src/modules/Elsa.Studio.ExternalAuthentication.Tests/Operations/RemoteFeatureProviderTests.cs b/src/modules/Elsa.Studio.ExternalAuthentication.Tests/Operations/RemoteFeatureProviderTests.cs index cb81498567..e626925583 100644 --- a/src/modules/Elsa.Studio.ExternalAuthentication.Tests/Operations/RemoteFeatureProviderTests.cs +++ b/src/modules/Elsa.Studio.ExternalAuthentication.Tests/Operations/RemoteFeatureProviderTests.cs @@ -25,6 +25,36 @@ public async Task FeatureChecks_ReuseTheInstalledFeatureCatalog() Assert.Equal(0, api.GetCalls); } + [Theory] + [InlineData("Elsa.Workflows.Runtime.Dashboard.ShellFeatures.WorkflowRuntimeDashboard", "Elsa.WorkflowRuntimeDashboard")] + [InlineData("Elsa.Diagnostics.StructuredLogs.Dashboard.ShellFeatures.StructuredLogsDashboard", "Elsa.StructuredLogsDashboard")] + [InlineData("Elsa.Diagnostics.StructuredLogs.ShellFeatures.StructuredLogs", "Elsa.StructuredLogs")] + [InlineData("Elsa.ExternalAuthentication.ShellFeatures.ExternalAuthentication", "Elsa.ExternalAuthentication")] + [InlineData("Elsa.Workflows.Runtime.Dashboard.ShellFeatures.WorkflowRuntimeDashboard", "Elsa.Workflows.Runtime.Dashboard.ShellFeatures.WorkflowRuntimeDashboard")] + public async Task FeatureChecks_AcceptStaticCatalogNames(string requestedName, string catalogFullName) + { + var api = new FeaturesApi + { + Features = new([new FeatureDescriptor { FullName = catalogFullName }], 1) + }; + var provider = new RemoteFeatureProvider(new BackendApiClientProvider(api)); + + Assert.True(await provider.IsEnabledAsync(requestedName)); + Assert.False(await provider.IsEnabledAsync("Elsa.MissingFeature")); + } + + [Fact] + public async Task FeatureChecks_AcceptCatalogNameAsFeatureId() + { + var api = new FeaturesApi + { + Features = new([new FeatureDescriptor { Name = "WorkflowRuntimeDashboard", Namespace = "Elsa" }], 1) + }; + var provider = new RemoteFeatureProvider(new BackendApiClientProvider(api)); + + Assert.True(await provider.IsEnabledAsync("Elsa.Workflows.Runtime.Dashboard.ShellFeatures.WorkflowRuntimeDashboard")); + } + [Fact] public async Task AnonymousFeatureChecks_DoNotProbeTheProtectedBackend() { @@ -128,6 +158,7 @@ private sealed class FeaturesApi : IFeaturesApi [new FeatureDescriptor { FullName = "Elsa.ExternalAuthentication" }], 1); + public ListResponse Features { get; set; } = InstalledFeatures; public Queue>>> Responses { get; } = new(); public int GetCalls { get; private set; } public int ListCalls { get; private set; } @@ -144,7 +175,7 @@ public Task> ListAsync(CancellationToken cancell if (Responses.TryDequeue(out var response)) return response(cancellationToken); - return Task.FromResult(InstalledFeatures); + return Task.FromResult(Features); } } From c9a36b3335941c62867f9e90e2d7918932b0c2e3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 11:10:04 +0000 Subject: [PATCH 007/169] fix: restrict static feature catalog matching to Elsa-prefixed names Drop unrestricted last-segment matching so a foreign catalog entry such as Acme.WorkflowRuntimeDashboard cannot enable an Elsa Studio companion. Short-name fallback now requires FullName Elsa.{FeatureId} or Name + Namespace Elsa. Co-authored-by: Sipke Schoorstra --- .../DefaultFeatureServiceTests.cs | 15 +++++++++++++++ .../Services/RemoteFeatureCatalog.cs | 6 ++++-- .../Operations/RemoteFeatureProviderTests.cs | 17 +++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/framework/Elsa.Studio.Core.Tests/DefaultFeatureServiceTests.cs b/src/framework/Elsa.Studio.Core.Tests/DefaultFeatureServiceTests.cs index aac9518a2c..067689a690 100644 --- a/src/framework/Elsa.Studio.Core.Tests/DefaultFeatureServiceTests.cs +++ b/src/framework/Elsa.Studio.Core.Tests/DefaultFeatureServiceTests.cs @@ -52,6 +52,21 @@ public async Task InitializeFeatures_SkipsCompanionsMissingFromCatalog() Assert.False(dashboard.Initialized); } + [Fact] + public async Task InitializeFeatures_SkipsCompanionsWhenCatalogLastSegmentIsNotElsaPrefixed() + { + var dashboard = new WorkflowRuntimeDashboardFeature(); + var service = new DefaultFeatureService( + [dashboard], + new CatalogRemoteFeatureProvider( + new FeatureDescriptor { FullName = "Acme.WorkflowRuntimeDashboard" }, + new FeatureDescriptor { Name = "WorkflowRuntimeDashboard", Namespace = "Acme" })); + + await service.InitializeFeaturesAsync(); + + Assert.False(dashboard.Initialized); + } + [Fact] public async Task InitializeFeatures_AlwaysInitializesUngatedFeatures() { diff --git a/src/framework/Elsa.Studio.Core/Services/RemoteFeatureCatalog.cs b/src/framework/Elsa.Studio.Core/Services/RemoteFeatureCatalog.cs index f5630d955d..e82eac0bf5 100644 --- a/src/framework/Elsa.Studio.Core/Services/RemoteFeatureCatalog.cs +++ b/src/framework/Elsa.Studio.Core/Services/RemoteFeatureCatalog.cs @@ -11,6 +11,8 @@ namespace Elsa.Studio.Services; /// CShells feature id as Elsa.{FeatureId} (for example /// Elsa.WorkflowRuntimeDashboard). Studio modules request the ShellFeatures name, /// so both catalog forms must resolve as the same installed feature. +/// Short-name fallback is restricted to the Elsa namespace so an unrelated +/// Acme.{FeatureId} catalog entry cannot enable an Elsa Studio module. /// internal static class RemoteFeatureCatalog { @@ -25,11 +27,11 @@ public static bool Matches(FeatureDescriptor feature, string featureName) var requestedId = GetFeatureId(featureName); if (!string.IsNullOrEmpty(feature.Name) && + string.Equals(feature.Namespace, "Elsa", StringComparison.Ordinal) && string.Equals(feature.Name, requestedId, StringComparison.Ordinal)) return true; - return !string.IsNullOrEmpty(feature.FullName) && - string.Equals(GetFeatureId(feature.FullName), requestedId, StringComparison.Ordinal); + return string.Equals(feature.FullName, $"Elsa.{requestedId}", StringComparison.Ordinal); } private static string GetFeatureId(string featureName) diff --git a/src/modules/Elsa.Studio.ExternalAuthentication.Tests/Operations/RemoteFeatureProviderTests.cs b/src/modules/Elsa.Studio.ExternalAuthentication.Tests/Operations/RemoteFeatureProviderTests.cs index e626925583..1daea15278 100644 --- a/src/modules/Elsa.Studio.ExternalAuthentication.Tests/Operations/RemoteFeatureProviderTests.cs +++ b/src/modules/Elsa.Studio.ExternalAuthentication.Tests/Operations/RemoteFeatureProviderTests.cs @@ -55,6 +55,23 @@ public async Task FeatureChecks_AcceptCatalogNameAsFeatureId() Assert.True(await provider.IsEnabledAsync("Elsa.Workflows.Runtime.Dashboard.ShellFeatures.WorkflowRuntimeDashboard")); } + [Fact] + public async Task FeatureChecks_RejectNonElsaLastSegmentCollisions() + { + const string requestedName = "Elsa.Workflows.Runtime.Dashboard.ShellFeatures.WorkflowRuntimeDashboard"; + var foreignFullName = new FeaturesApi + { + Features = new([new FeatureDescriptor { FullName = "Acme.WorkflowRuntimeDashboard" }], 1) + }; + var foreignNamespace = new FeaturesApi + { + Features = new([new FeatureDescriptor { Name = "WorkflowRuntimeDashboard", Namespace = "Acme" }], 1) + }; + + Assert.False(await new RemoteFeatureProvider(new BackendApiClientProvider(foreignFullName)).IsEnabledAsync(requestedName)); + Assert.False(await new RemoteFeatureProvider(new BackendApiClientProvider(foreignNamespace)).IsEnabledAsync(requestedName)); + } + [Fact] public async Task AnonymousFeatureChecks_DoNotProbeTheProtectedBackend() { From 8d5ece82e364f5c169542ef2d2352f4b21e38213 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 11:12:30 +0000 Subject: [PATCH 008/169] test: reject Acme.Identity last-segment collisions Cover the Greptile Identity-style foreign-namespace case alongside WorkflowRuntimeDashboard so a non-Elsa catalog entry cannot enable an Elsa companion. Co-authored-by: Sipke Schoorstra --- .../DefaultFeatureServiceTests.cs | 11 +++++++++-- .../Operations/RemoteFeatureProviderTests.cs | 19 ++++++++++--------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/framework/Elsa.Studio.Core.Tests/DefaultFeatureServiceTests.cs b/src/framework/Elsa.Studio.Core.Tests/DefaultFeatureServiceTests.cs index 067689a690..729479aabe 100644 --- a/src/framework/Elsa.Studio.Core.Tests/DefaultFeatureServiceTests.cs +++ b/src/framework/Elsa.Studio.Core.Tests/DefaultFeatureServiceTests.cs @@ -56,15 +56,19 @@ public async Task InitializeFeatures_SkipsCompanionsMissingFromCatalog() public async Task InitializeFeatures_SkipsCompanionsWhenCatalogLastSegmentIsNotElsaPrefixed() { var dashboard = new WorkflowRuntimeDashboardFeature(); + var identity = new IdentityFeature(); var service = new DefaultFeatureService( - [dashboard], + [dashboard, identity], new CatalogRemoteFeatureProvider( new FeatureDescriptor { FullName = "Acme.WorkflowRuntimeDashboard" }, - new FeatureDescriptor { Name = "WorkflowRuntimeDashboard", Namespace = "Acme" })); + new FeatureDescriptor { Name = "WorkflowRuntimeDashboard", Namespace = "Acme" }, + new FeatureDescriptor { FullName = "Acme.Identity" }, + new FeatureDescriptor { Name = "Identity", Namespace = "Acme" })); await service.InitializeFeaturesAsync(); Assert.False(dashboard.Initialized); + Assert.False(identity.Initialized); } [Fact] @@ -89,6 +93,9 @@ private sealed class WorkflowRuntimeDashboardFeature : TrackingFeature [RemoteFeature("Elsa.Diagnostics.StructuredLogs.Dashboard.ShellFeatures.StructuredLogsDashboard")] private sealed class StructuredLogsDashboardFeature : TrackingFeature; + [RemoteFeature("Elsa.Identity.ShellFeatures.Identity")] + private sealed class IdentityFeature : TrackingFeature; + private sealed class LocalFeature : TrackingFeature; private abstract class TrackingFeature : IFeature diff --git a/src/modules/Elsa.Studio.ExternalAuthentication.Tests/Operations/RemoteFeatureProviderTests.cs b/src/modules/Elsa.Studio.ExternalAuthentication.Tests/Operations/RemoteFeatureProviderTests.cs index 1daea15278..f07ff2485c 100644 --- a/src/modules/Elsa.Studio.ExternalAuthentication.Tests/Operations/RemoteFeatureProviderTests.cs +++ b/src/modules/Elsa.Studio.ExternalAuthentication.Tests/Operations/RemoteFeatureProviderTests.cs @@ -55,21 +55,22 @@ public async Task FeatureChecks_AcceptCatalogNameAsFeatureId() Assert.True(await provider.IsEnabledAsync("Elsa.Workflows.Runtime.Dashboard.ShellFeatures.WorkflowRuntimeDashboard")); } - [Fact] - public async Task FeatureChecks_RejectNonElsaLastSegmentCollisions() + [Theory] + [InlineData("Elsa.Workflows.Runtime.Dashboard.ShellFeatures.WorkflowRuntimeDashboard", "Acme.WorkflowRuntimeDashboard", "WorkflowRuntimeDashboard")] + [InlineData("Elsa.Identity.ShellFeatures.Identity", "Acme.Identity", "Identity")] + public async Task FeatureChecks_RejectNonElsaLastSegmentCollisions(string requestedName, string foreignFullName, string foreignName) { - const string requestedName = "Elsa.Workflows.Runtime.Dashboard.ShellFeatures.WorkflowRuntimeDashboard"; - var foreignFullName = new FeaturesApi + var fullNameCatalog = new FeaturesApi { - Features = new([new FeatureDescriptor { FullName = "Acme.WorkflowRuntimeDashboard" }], 1) + Features = new([new FeatureDescriptor { FullName = foreignFullName }], 1) }; - var foreignNamespace = new FeaturesApi + var namespaceCatalog = new FeaturesApi { - Features = new([new FeatureDescriptor { Name = "WorkflowRuntimeDashboard", Namespace = "Acme" }], 1) + Features = new([new FeatureDescriptor { Name = foreignName, Namespace = "Acme" }], 1) }; - Assert.False(await new RemoteFeatureProvider(new BackendApiClientProvider(foreignFullName)).IsEnabledAsync(requestedName)); - Assert.False(await new RemoteFeatureProvider(new BackendApiClientProvider(foreignNamespace)).IsEnabledAsync(requestedName)); + Assert.False(await new RemoteFeatureProvider(new BackendApiClientProvider(fullNameCatalog)).IsEnabledAsync(requestedName)); + Assert.False(await new RemoteFeatureProvider(new BackendApiClientProvider(namespaceCatalog)).IsEnabledAsync(requestedName)); } [Fact] From b267a8b3b218c29e2ab53415dc9b7f1b6a09fe1c Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sat, 12 Sep 2026 04:47:12 -0700 Subject: [PATCH 009/169] fix: open Execution Details drawer when clicking an Executions row (#1028) * fix: open Execution Details drawer when clicking an Executions row MudBlazor 9 left the persistent, always-mounted drawer off-canvas after @bind-Open flipped the parent flag. Mount the drawer only while open as a Temporary overlay so State/Outcomes/Output/Retry Attempts render. Fixes #1021 Co-authored-by: Sipke Schoorstra * test: cover production drawer open and dismiss paths Extract ActivityExecutionDetailsDrawer so tests hit the real mount/close path (row select, header close, overlay) instead of a copied harness. Co-authored-by: Sipke Schoorstra --------- Co-authored-by: Cursor Agent --- .../ActivityExecutionDetailsTests.cs | 148 ++++++++++++ .../ActivityExecutionsTabTests.cs | 76 +++++++ .../ExecutionDetailsDrawerRenderTests.cs | 210 ++++++++++++++++++ .../ActivityExecutionDetailsDrawer.razor | 27 +++ .../ActivityExecutionDetailsDrawer.razor.cs | 31 +++ .../WorkflowInstanceViewer.razor | 23 +- .../WorkflowInstanceViewer.razor.cs | 17 +- 7 files changed, 510 insertions(+), 22 deletions(-) create mode 100644 src/modules/Elsa.Studio.Workflows.Tests/ActivityExecutionDetailsTests.cs create mode 100644 src/modules/Elsa.Studio.Workflows.Tests/ActivityExecutionsTabTests.cs create mode 100644 src/modules/Elsa.Studio.Workflows.Tests/ExecutionDetailsDrawerRenderTests.cs create mode 100644 src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/Components/ActivityExecutionDetailsDrawer.razor create mode 100644 src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/Components/ActivityExecutionDetailsDrawer.razor.cs diff --git a/src/modules/Elsa.Studio.Workflows.Tests/ActivityExecutionDetailsTests.cs b/src/modules/Elsa.Studio.Workflows.Tests/ActivityExecutionDetailsTests.cs new file mode 100644 index 0000000000..295ea0566f --- /dev/null +++ b/src/modules/Elsa.Studio.Workflows.Tests/ActivityExecutionDetailsTests.cs @@ -0,0 +1,148 @@ +using Bunit; +using Elsa.Api.Client.Resources.ActivityExecutions.Models; +using Elsa.Api.Client.Resources.Resilience.Models; +using Elsa.Api.Client.Shared.Models; +using Elsa.Studio.DomInterop.Contracts; +using Elsa.Studio.Localization; +using Elsa.Studio.Workflows.Components.WorkflowInstanceViewer.Components; +using Elsa.Studio.Workflows.Domain.Contracts; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; +using MudBlazor; +using MudBlazor.Services; +using Xunit; + +namespace Elsa.Studio.Workflows.Tests; + +public sealed class ActivityExecutionDetailsTests : BunitContext, IAsyncLifetime +{ + private readonly ActivityExecutionServiceStub _executionService = new(); + + public ActivityExecutionDetailsTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddMudServices(); + Services.AddSingleton(); + Services.AddSingleton(_executionService); + Services.AddSingleton(); + Render(); + } + + Task IAsyncLifetime.InitializeAsync() => Task.CompletedTask; + async Task IAsyncLifetime.DisposeAsync() => await base.DisposeAsync(); + + [Fact] + public void RendersStateOutcomesAndOutputForTheSelectedExecution() + { + var record = new ActivityExecutionRecord + { + Id = "exec-1", + ActivityId = "WriteLine1", + ActivityNodeId = "Workflow1:WriteLine1", + ActivityType = "Elsa.WriteLine", + Status = ActivityStatus.Completed, + StartedAt = DateTimeOffset.UtcNow, + CompletedAt = DateTimeOffset.UtcNow, + ActivityState = new Dictionary + { + ["Text"] = "hello", + ["_internal"] = "hidden" + }, + Payload = new Dictionary + { + ["Outcomes"] = "Done" + }, + Outputs = new Dictionary + { + ["Result"] = "ok" + } + }; + + var cut = Render(parameters => parameters + .Add(details => details.ActivityExecution, record)); + + cut.WaitForAssertion(() => + { + Assert.Contains("State", cut.Markup); + Assert.Contains("Text", cut.Markup); + Assert.Contains("hello", cut.Markup); + Assert.DoesNotContain("_internal", cut.Markup); + Assert.Contains("Outcomes", cut.Markup); + Assert.Contains("Done", cut.Markup); + Assert.Contains("Output", cut.Markup); + Assert.Contains("Result", cut.Markup); + Assert.Contains("ok", cut.Markup); + }); + } + + [Fact] + public void RendersRetryAttemptsWhenTheExecutionHasRetries() + { + _executionService.Retries = new PagedListResponse + { + Items = + [ + new RetryAttemptRecord + { + AttemptNumber = 1, + RetryDelay = TimeSpan.FromSeconds(1), + Details = new Dictionary { ["Reason"] = "timeout" } + } + ], + TotalCount = 1 + }; + + var record = new ActivityExecutionRecord + { + Id = "exec-retry", + ActivityId = "HttpRequest1", + ActivityNodeId = "Workflow1:HttpRequest1", + ActivityType = "Elsa.HttpRequest", + Status = ActivityStatus.Completed, + StartedAt = DateTimeOffset.UtcNow + }; + + var cut = Render(parameters => parameters + .Add(details => details.ActivityExecution, record)); + + cut.WaitForAssertion(() => + { + Assert.Contains("Retry Attempts", cut.Markup); + Assert.Contains("timeout", cut.Markup); + }); + } + + private sealed class ActivityExecutionServiceStub : IActivityExecutionService + { + public PagedListResponse Retries { get; set; } = new(); + + public Task GetReportAsync(string workflowInstanceId, System.Text.Json.Nodes.JsonObject containerActivity, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public Task> ListAsync(string workflowInstanceId, string activityNodeId, CancellationToken cancellationToken = default) => + Task.FromResult(Enumerable.Empty()); + + public Task> ListSummariesAsync(string workflowInstanceId, string activityNodeId, CancellationToken cancellationToken = default) => + Task.FromResult(Enumerable.Empty()); + + public Task GetAsync(string id, CancellationToken cancellationToken = default) => + Task.FromResult(null); + + public Task GetCallStackAsync(string activityExecutionId, bool? includeCrossWorkflowChain = null, int? skip = null, int? take = null, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public Task> GetRetriesAsync(string activityInstanceId, int? skip = null, int? take = null, CancellationToken cancellationToken = default) => + Task.FromResult(Retries); + } + + private sealed class ClipboardStub : IClipboard + { + public Task CopyText(string text, CancellationToken cancellationToken = default) => Task.CompletedTask; + } + + private sealed class TestLocalizer : ILocalizer + { + public LocalizedString this[string? key] => new(key ?? string.Empty, key ?? string.Empty); + public LocalizedString this[string? key, params object[] arguments] => new(key ?? string.Empty, string.Format(key ?? string.Empty, arguments)); + } +} diff --git a/src/modules/Elsa.Studio.Workflows.Tests/ActivityExecutionsTabTests.cs b/src/modules/Elsa.Studio.Workflows.Tests/ActivityExecutionsTabTests.cs new file mode 100644 index 0000000000..2d5c7938c9 --- /dev/null +++ b/src/modules/Elsa.Studio.Workflows.Tests/ActivityExecutionsTabTests.cs @@ -0,0 +1,76 @@ +using Bunit; +using Elsa.Api.Client.Resources.ActivityExecutions.Models; +using Elsa.Api.Client.Shared.Models; +using Elsa.Studio.Localization; +using Elsa.Studio.Localization.Time; +using Elsa.Studio.Workflows.Components.WorkflowInstanceViewer.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; +using MudBlazor; +using MudBlazor.Services; +using Xunit; + +namespace Elsa.Studio.Workflows.Tests; + +public sealed class ActivityExecutionsTabTests : BunitContext, IAsyncLifetime +{ + public ActivityExecutionsTabTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddMudServices(); + Services.AddSingleton(); + Services.AddSingleton(); + Render(); + } + + Task IAsyncLifetime.InitializeAsync() => Task.CompletedTask; + async Task IAsyncLifetime.DisposeAsync() => await base.DisposeAsync(); + + [Fact] + public async Task ClickingAnExecutionRow_RaisesExecutionSelectedWithThatRecordId() + { + string? selectedId = null; + var summaries = new List + { + CreateSummary("exec-a"), + CreateSummary("exec-b") + }; + + var cut = Render(parameters => parameters + .Add(tab => tab.ActivityExecutionSummaries, summaries) + .Add(tab => tab.VisiblePaneHeight, 400) + .Add(tab => tab.ExecutionSelected, id => selectedId = id)); + + var rows = cut.FindAll("tbody tr"); + Assert.Equal(2, rows.Count); + + await rows[1].ClickAsync(); + + cut.WaitForAssertion(() => Assert.Equal("exec-b", selectedId)); + } + + private static ActivityExecutionRecordSummary CreateSummary(string id) + { + return new ActivityExecutionRecordSummary + { + Id = id, + ActivityId = "WriteLine1", + ActivityNodeId = "Workflow1:WriteLine1", + ActivityType = "Elsa.WriteLine", + Status = ActivityStatus.Completed, + StartedAt = DateTimeOffset.UtcNow + }; + } + + private sealed class TestLocalizer : ILocalizer + { + public LocalizedString this[string? key] => new(key ?? string.Empty, key ?? string.Empty); + public LocalizedString this[string? key, params object[] arguments] => new(key ?? string.Empty, string.Format(key ?? string.Empty, arguments)); + } + + private sealed class TestTimeFormatter : ITimeFormatter + { + public string Format(DateTimeOffset? value, string format = "G", string emptyString = "") => + value?.ToString(format) ?? emptyString; + } +} diff --git a/src/modules/Elsa.Studio.Workflows.Tests/ExecutionDetailsDrawerRenderTests.cs b/src/modules/Elsa.Studio.Workflows.Tests/ExecutionDetailsDrawerRenderTests.cs new file mode 100644 index 0000000000..1b8c275a28 --- /dev/null +++ b/src/modules/Elsa.Studio.Workflows.Tests/ExecutionDetailsDrawerRenderTests.cs @@ -0,0 +1,210 @@ +using Bunit; +using Elsa.Api.Client.Resources.ActivityExecutions.Models; +using Elsa.Api.Client.Shared.Models; +using Elsa.Studio.DomInterop.Contracts; +using Elsa.Studio.Localization; +using Elsa.Studio.Localization.Time; +using Elsa.Studio.Workflows.Components.WorkflowInstanceViewer.Components; +using Elsa.Studio.Workflows.Domain.Contracts; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; +using MudBlazor; +using MudBlazor.Services; +using Xunit; + +namespace Elsa.Studio.Workflows.Tests; + +public sealed class ExecutionDetailsDrawerRenderTests : BunitContext, IAsyncLifetime +{ + private readonly ActivityExecutionServiceStub _executionService = new(); + + public ExecutionDetailsDrawerRenderTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddMudServices(); + Services.AddSingleton(); + Services.AddSingleton(); + Services.AddSingleton(); + Services.AddSingleton(_executionService); + Render(); + } + + Task IAsyncLifetime.InitializeAsync() => Task.CompletedTask; + async Task IAsyncLifetime.DisposeAsync() => await base.DisposeAsync(); + + [Fact] + public void SelectingAnExecution_MountsTheOpenDrawerWithThatExecutionsDetails() + { + var record = CreateRecord("exec-1", "hello", "Done", "ok"); + _executionService.Record = record; + + var cut = Render(parameters => parameters + .Add(host => host.Summaries, [CreateSummary("exec-1"), CreateSummary("exec-2")])); + + Assert.Empty(cut.FindAll("aside.execution-details-drawer")); + + cut.FindAll("tbody tr")[0].Click(); + + cut.WaitForAssertion(() => + { + var drawer = cut.Find("aside.execution-details-drawer"); + Assert.Contains("mud-drawer--open", drawer.ClassList); + Assert.Contains("mud-drawer-temporary", drawer.ClassList); + Assert.Contains("Execution Details", cut.Markup); + Assert.Contains("hello", cut.Markup); + Assert.Contains("Done", cut.Markup); + Assert.Contains("ok", cut.Markup); + }); + } + + [Fact] + public void HeaderClose_DismissesTheDrawer() + { + var cut = Render(parameters => parameters + .Add(host => host.InitiallyOpen, true) + .Add(host => host.ActivityExecution, CreateRecord("exec-1", "hello", "Done", "ok"))); + + Assert.NotEmpty(cut.FindAll("aside.execution-details-drawer")); + + cut.Find("button[aria-label='Close']").Click(); + + cut.WaitForAssertion(() => Assert.Empty(cut.FindAll("aside.execution-details-drawer"))); + } + + [Fact] + public async Task OverlayDismissal_UnmountsTheDrawer() + { + var cut = Render(parameters => parameters + .Add(host => host.InitiallyOpen, true) + .Add(host => host.ActivityExecution, CreateRecord("exec-1", "hello", "Done", "ok"))); + + var drawer = cut.FindComponent(); + await cut.InvokeAsync(() => drawer.Instance.OpenChanged.InvokeAsync(false)); + + cut.WaitForAssertion(() => Assert.Empty(cut.FindAll("aside.execution-details-drawer"))); + } + + private static ActivityExecutionRecord CreateRecord(string id, string text, string outcome, string output) => new() + { + Id = id, + ActivityId = "WriteLine1", + ActivityNodeId = "Workflow1:WriteLine1", + ActivityType = "Elsa.WriteLine", + Status = ActivityStatus.Completed, + StartedAt = DateTimeOffset.UtcNow, + ActivityState = new Dictionary { ["Text"] = text }, + Payload = new Dictionary { ["Outcomes"] = outcome }, + Outputs = new Dictionary { ["Result"] = output } + }; + + private static ActivityExecutionRecordSummary CreateSummary(string id) => new() + { + Id = id, + ActivityId = "WriteLine1", + ActivityNodeId = "Workflow1:WriteLine1", + ActivityType = "Elsa.WriteLine", + Status = ActivityStatus.Completed, + StartedAt = DateTimeOffset.UtcNow + }; + + /// + /// Owns drawer open-state the same way WorkflowInstanceViewer does. + /// + private sealed class DrawerHost : ComponentBase + { + [Parameter] public bool InitiallyOpen { get; set; } + [Parameter] public ActivityExecutionRecord? ActivityExecution { get; set; } + + private bool _open; + + protected override void OnInitialized() => _open = InitiallyOpen; + + protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) + { + builder.OpenComponent(0); + builder.AddAttribute(1, "Open", _open); + builder.AddAttribute(2, "ActivityExecution", ActivityExecution); + builder.AddAttribute(3, "OpenChanged", EventCallback.Factory.Create(this, OnOpenChanged)); + builder.CloseComponent(); + } + + private void OnOpenChanged(bool open) => _open = open; + } + + /// + /// Same wiring as WorkflowInstanceViewer: Executions-tab row click loads the record and opens the production drawer. + /// The full viewer also mounts Journal, the designer, and SignalR, which is not practical in bUnit. + /// + private sealed class ExecutionSelectionHost : ComponentBase + { + [Parameter] public ICollection Summaries { get; set; } = []; + [Inject] private IActivityExecutionService ActivityExecutionService { get; set; } = null!; + + private bool _open; + private ActivityExecutionRecord? _record; + + protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) + { + builder.OpenComponent(0); + builder.AddAttribute(1, "ActivityExecutionSummaries", Summaries); + builder.AddAttribute(2, "VisiblePaneHeight", 400); + builder.AddAttribute(3, "ExecutionSelected", EventCallback.Factory.Create(this, OnExecutionSelected)); + builder.CloseComponent(); + + builder.OpenComponent(4); + builder.AddAttribute(5, "Open", _open); + builder.AddAttribute(6, "ActivityExecution", _record); + builder.AddAttribute(7, "OpenChanged", EventCallback.Factory.Create(this, OnOpenChanged)); + builder.CloseComponent(); + } + + private async Task OnExecutionSelected(string executionId) + { + _record = await ActivityExecutionService.GetAsync(executionId); + _open = _record != null; + } + + private void OnOpenChanged(bool open) => _open = open; + } + + private sealed class ActivityExecutionServiceStub : IActivityExecutionService + { + public ActivityExecutionRecord? Record { get; set; } + + public Task GetReportAsync(string workflowInstanceId, System.Text.Json.Nodes.JsonObject containerActivity, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public Task> ListAsync(string workflowInstanceId, string activityNodeId, CancellationToken cancellationToken = default) => + Task.FromResult(Enumerable.Empty()); + + public Task> ListSummariesAsync(string workflowInstanceId, string activityNodeId, CancellationToken cancellationToken = default) => + Task.FromResult(Enumerable.Empty()); + + public Task GetAsync(string id, CancellationToken cancellationToken = default) => + Task.FromResult(id == Record?.Id ? Record : null); + + public Task GetCallStackAsync(string activityExecutionId, bool? includeCrossWorkflowChain = null, int? skip = null, int? take = null, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public Task> GetRetriesAsync(string activityInstanceId, int? skip = null, int? take = null, CancellationToken cancellationToken = default) => + Task.FromResult(new PagedListResponse()); + } + + private sealed class ClipboardStub : IClipboard + { + public Task CopyText(string text, CancellationToken cancellationToken = default) => Task.CompletedTask; + } + + private sealed class TestTimeFormatter : ITimeFormatter + { + public string Format(DateTimeOffset? value, string format = "G", string emptyString = "") => + value?.ToString(format) ?? emptyString; + } + + private sealed class TestLocalizer : ILocalizer + { + public LocalizedString this[string? key] => new(key ?? string.Empty, key ?? string.Empty); + public LocalizedString this[string? key, params object[] arguments] => new(key ?? string.Empty, string.Format(key ?? string.Empty, arguments)); + } +} diff --git a/src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/Components/ActivityExecutionDetailsDrawer.razor b/src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/Components/ActivityExecutionDetailsDrawer.razor new file mode 100644 index 0000000000..43d339388c --- /dev/null +++ b/src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/Components/ActivityExecutionDetailsDrawer.razor @@ -0,0 +1,27 @@ +@inherits StudioComponentBase +@inject ILocalizer Localizer + +@* Mount only while open so MudBlazor 9 applies mud-drawer--open on first render. + Persistent + @bind-Open left the parent flag true while the drawer stayed closed/off-canvas. *@ +@if (Open) +{ + + + @Localizer["Execution Details"] + + + + +
+ +
+
+} diff --git a/src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/Components/ActivityExecutionDetailsDrawer.razor.cs b/src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/Components/ActivityExecutionDetailsDrawer.razor.cs new file mode 100644 index 0000000000..a34db80ff2 --- /dev/null +++ b/src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/Components/ActivityExecutionDetailsDrawer.razor.cs @@ -0,0 +1,31 @@ +using Elsa.Api.Client.Resources.ActivityExecutions.Models; +using Microsoft.AspNetCore.Components; + +namespace Elsa.Studio.Workflows.Components.WorkflowInstanceViewer.Components; + +/// +/// Right-side drawer that shows one activity execution's State, Outcomes, Output, and Retry Attempts. +/// +public partial class ActivityExecutionDetailsDrawer +{ + /// + /// Whether the drawer is open. + /// + [Parameter] public bool Open { get; set; } + + /// + /// Raised when the drawer requests an open-state change (header close or overlay). + /// + [Parameter] public EventCallback OpenChanged { get; set; } + + /// + /// The execution record to display. + /// + [Parameter] public ActivityExecutionRecord? ActivityExecution { get; set; } + + private Task Close() => OnOpenChanged(false); + + private Task OnOpenChanged(bool open) => OpenChanged.HasDelegate + ? OpenChanged.InvokeAsync(open) + : Task.CompletedTask; +} diff --git a/src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/WorkflowInstanceViewer.razor b/src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/WorkflowInstanceViewer.razor index 1773f48dba..f0be9a4a2d 100644 --- a/src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/WorkflowInstanceViewer.razor +++ b/src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/WorkflowInstanceViewer.razor @@ -2,8 +2,8 @@ @using Orientation = Radzen.Orientation @inject ILocalizer Localizer - -
+ +
@@ -36,20 +36,7 @@
- - - @Localizer["Execution Details"] - - - - -
- -
-
+
diff --git a/src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/WorkflowInstanceViewer.razor.cs b/src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/WorkflowInstanceViewer.razor.cs index 0cf3c57252..bd713f5c73 100644 --- a/src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/WorkflowInstanceViewer.razor.cs +++ b/src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/WorkflowInstanceViewer.razor.cs @@ -177,8 +177,7 @@ private async Task OnCallStackEntrySelected(ActivityExecutionRecord record) _programmaticallySelectedNodeId = record.ActivityNodeId; _selectedActivityNodeId = record.ActivityNodeId; _selectedExecutionForDrawer = record; - _isExecutionDetailsDrawerOpen = true; - StateHasChanged(); // Open drawer and highlight immediately. + OpenExecutionDetailsDrawer(); await _workspace.SelectActivityByIdAsync(record.ActivityId, record.ActivityNodeId); } @@ -225,11 +224,21 @@ private async Task OnActivityExecutionSelected(string? executionId) _selectedActivityExecutionRecordId = record.Id; _selectedActivityNodeId = record.ActivityNodeId; _selectedExecutionForDrawer = record; - _isExecutionDetailsDrawerOpen = true; - StateHasChanged(); + OpenExecutionDetailsDrawer(); } } + private void OpenExecutionDetailsDrawer() + { + _isExecutionDetailsDrawerOpen = true; + StateHasChanged(); + } + + private void OnExecutionDetailsDrawerOpenChanged(bool open) + { + _isExecutionDetailsDrawerOpen = open; + } + async ValueTask IAsyncDisposable.DisposeAsync() { await DisposeObserverAsync(); From d7255c401365f493c46c2913ddcf99ace1b2114d Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sat, 12 Sep 2026 06:13:17 -0700 Subject: [PATCH 010/169] fix: allow TryMarkInterrupted to promote Finished/Cancelled (#190) * fix: allow TryMarkInterrupted to promote Finished/Cancelled Align Mongo, Dapper, and Elasticsearch TryMarkInterruptedAsync with elsa-core#8069 so drain force-cancel (Finished/Cancelled) can become Running+Interrupted with IsExecuting=false. Naturally completed Finished/Finished and Finished/Faulted remain refused (#8052). Co-authored-by: Sipke Schoorstra * fix: use UpdateByQuery for ES TryMarkInterrupted Replace Find+SaveAsync with a conditional UpdateByQuery so Elasticsearch matches core#8069's atomic write shape. Predicate is Status != Finished OR SubStatus == Cancelled; success sets Running+Interrupted and IsExecuting=false. Co-authored-by: Sipke Schoorstra --------- Co-authored-by: Cursor Agent --- .../Stores/DapperWorkflowInstanceStore.cs | 17 +++- .../Common/ElasticStore.cs | 13 +++ .../Management/WorkflowInstanceStore.cs | 38 ++++++--- .../Management/WorkflowInstanceStore.cs | 7 +- .../DapperWorkflowInstanceStoreTests.cs | 85 +++++++++++++++++-- .../TryMarkInterruptedQueryTests.cs | 21 +++-- .../MongoWorkflowInstanceStoreTests.cs | 40 +++++++-- 7 files changed, 183 insertions(+), 38 deletions(-) diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowInstanceStore.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowInstanceStore.cs index 28ef88d95f..380549688d 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowInstanceStore.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowInstanceStore.cs @@ -176,16 +176,25 @@ public async ValueTask TryMarkInterruptedAsync(string workflowInstanceId, var record = new WorkflowInstanceRecord { Id = workflowInstanceId, + Status = WorkflowStatus.Running.ToString(), SubStatus = WorkflowSubStatus.Interrupted.ToString(), IsExecuting = false }; var updated = await store.UpdateAsync( record, - [x => x.SubStatus, x => x.IsExecuting], - q => q - .Is(nameof(WorkflowInstanceRecord.Id), workflowInstanceId) - .IsNot(nameof(WorkflowInstanceRecord.Status), WorkflowStatus.Finished.ToString()), + [x => x.Status, x => x.SubStatus, x => x.IsExecuting], + q => + { + q.Is(nameof(WorkflowInstanceRecord.Id), workflowInstanceId); + // Naturally completed rows (Finished && SubStatus != Cancelled) must not be + // interrupted. Finished/Cancelled is the runner's drain force-cancel commit + // and is interruptible (elsa-core#8069). Distinct param names avoid colliding + // with the SET Status = Running assignment. + q.Sql.AppendLine("and (not Status = @FinishedStatus or SubStatus = @CancelledSubStatus)"); + q.Parameters.Add("@FinishedStatus", WorkflowStatus.Finished.ToString()); + q.Parameters.Add("@CancelledSubStatus", WorkflowSubStatus.Cancelled.ToString()); + }, cancellationToken); return updated > 0; diff --git a/src/modules/persistence/Elsa.Persistence.Elasticsearch/Common/ElasticStore.cs b/src/modules/persistence/Elsa.Persistence.Elasticsearch/Common/ElasticStore.cs index 1a8069fadd..d1bb15a48c 100644 --- a/src/modules/persistence/Elsa.Persistence.Elasticsearch/Common/ElasticStore.cs +++ b/src/modules/persistence/Elsa.Persistence.Elasticsearch/Common/ElasticStore.cs @@ -124,4 +124,17 @@ public async Task DeleteByQueryAsync(Action + /// Updates documents matching the specified query. Returns the number of documents updated. + /// + public async Task UpdateByQueryAsync(Action> query, CancellationToken cancellationToken) + { + var response = await _elasticClient.UpdateByQueryAsync(Indices.All, query, cancellationToken); + + if (!response.IsSuccess()) + throw new Exception(response.DebugInformation); + + return response.Updated ?? 0; + } } \ No newline at end of file diff --git a/src/modules/persistence/Elsa.Persistence.Elasticsearch/Modules/Management/WorkflowInstanceStore.cs b/src/modules/persistence/Elsa.Persistence.Elasticsearch/Modules/Management/WorkflowInstanceStore.cs index d919b977be..6f96cfabbd 100644 --- a/src/modules/persistence/Elsa.Persistence.Elasticsearch/Modules/Management/WorkflowInstanceStore.cs +++ b/src/modules/persistence/Elsa.Persistence.Elasticsearch/Modules/Management/WorkflowInstanceStore.cs @@ -153,18 +153,32 @@ public Task UpdateUpdatedTimestampAsync(string workflowInstanceId, DateTimeOffse /// public async ValueTask TryMarkInterruptedAsync(string workflowInstanceId, CancellationToken cancellationToken = default) { - var instance = await FindAsync(new WorkflowInstanceFilter - { - Id = workflowInstanceId - }, cancellationToken); - - if (instance is null || instance.Status == WorkflowStatus.Finished) - return false; - - instance.SubStatus = WorkflowSubStatus.Interrupted; - instance.IsExecuting = false; - await _store.SaveAsync(instance, cancellationToken); - return true; + // Conditional write: do not SaveAsync a Find snapshot. Id is a document field + // (not mapped as _id), so Update-by-id is unavailable. UpdateByQuery applies + // the same interruptible predicate as EF/Mongo (elsa-core#8069): + // Status != Finished OR SubStatus == Cancelled. + var updated = await _store.UpdateByQueryAsync(d => d + .Refresh(true) + .Query(q => q.Bool(b => b + .Must( + m => m.Match(mt => mt.Field(f => f.Id).Query(workflowInstanceId)), + m => m.Bool(s => s + .Should( + sh => sh.Bool(n => n.MustNot(mn => mn.Match(mt => mt + .Field(f => f.Status) + .Query(WorkflowStatus.Finished.ToString()!)))), + sh => sh.Match(mt => mt + .Field(f => f.SubStatus) + .Query(WorkflowSubStatus.Cancelled.ToString()!))) + .MinimumShouldMatch(1))))) + .Script(s => s + .Source("ctx._source.status = params.status; ctx._source.subStatus = params.subStatus; ctx._source.isExecuting = params.isExecuting;") + .AddParam("status", WorkflowStatus.Running.ToString()) + .AddParam("subStatus", WorkflowSubStatus.Interrupted.ToString()) + .AddParam("isExecuting", false)), + cancellationToken); + + return updated > 0; } private static SearchRequestDescriptor Sort(SearchRequestDescriptor descriptor, WorkflowInstanceOrder order) diff --git a/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowInstanceStore.cs b/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowInstanceStore.cs index 1b65b9325d..43b262844c 100644 --- a/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowInstanceStore.cs +++ b/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowInstanceStore.cs @@ -142,10 +142,15 @@ public async Task UpdateUpdatedTimestampAsync(string workflowInstanceId, DateTim public async ValueTask TryMarkInterruptedAsync(string workflowInstanceId, CancellationToken cancellationToken = default) { var collection = mongoDbStore.GetCollection(); + // Interruptible = not naturally completed. Finished/Cancelled is the runner's commit + // after drain force-cancel and must become Running+Interrupted (elsa-core#8069). var filter = Builders.Filter.And( Builders.Filter.Eq(x => x.Id, workflowInstanceId), - Builders.Filter.Ne(x => x.Status, WorkflowStatus.Finished)); + Builders.Filter.Or( + Builders.Filter.Ne(x => x.Status, WorkflowStatus.Finished), + Builders.Filter.Eq(x => x.SubStatus, WorkflowSubStatus.Cancelled))); var update = Builders.Update + .Set(x => x.Status, WorkflowStatus.Running) .Set(x => x.SubStatus, WorkflowSubStatus.Interrupted) .Set(x => x.IsExecuting, false); diff --git a/test/modules/persistence/Elsa.Dapper.UnitTests/DapperWorkflowInstanceStoreTests.cs b/test/modules/persistence/Elsa.Dapper.UnitTests/DapperWorkflowInstanceStoreTests.cs index ea5f0e7d92..876ef68b27 100644 --- a/test/modules/persistence/Elsa.Dapper.UnitTests/DapperWorkflowInstanceStoreTests.cs +++ b/test/modules/persistence/Elsa.Dapper.UnitTests/DapperWorkflowInstanceStoreTests.cs @@ -15,15 +15,16 @@ public sealed class DapperWorkflowInstanceStoreTests : IDisposable private static readonly DateTimeOffset Cutoff = new(2026, 1, 1, 12, 5, 0, TimeSpan.Zero); private readonly string _databasePath = Path.Combine(Path.GetTempPath(), $"elsa-dapper-instances-{Guid.NewGuid():N}.db"); + private readonly string _connectionString; private readonly DapperWorkflowInstanceStore _store; private readonly TestTenantAccessor _tenantAccessor = new(); public DapperWorkflowInstanceStoreTests() { - var connectionString = new SqliteConnectionStringBuilder { DataSource = _databasePath, Pooling = false }.ToString(); - var connectionProvider = new SqliteDbConnectionProvider(connectionString); + _connectionString = new SqliteConnectionStringBuilder { DataSource = _databasePath, Pooling = false }.ToString(); + var connectionProvider = new SqliteDbConnectionProvider(_connectionString); - using var connection = new SqliteConnection(connectionString); + using var connection = new SqliteConnection(_connectionString); connection.Open(); connection.Execute(""" create table WorkflowInstances ( @@ -105,23 +106,97 @@ public async Task CountAsync_WithBeforeLastUpdated_CountsOnlyInstancesUpdatedBef Assert.Equal(2, count); } + [Fact(DisplayName = "TryMarkInterruptedAsync marks a Running instance as Running+Interrupted")] + public async Task TryMarkInterruptedAsync_MarksRunningInstance() + { + InsertInterruptible("running-1", WorkflowStatus.Running, WorkflowSubStatus.Executing, isExecuting: true); + + using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + var marked = await _store.TryMarkInterruptedAsync("running-1"); + + Assert.True(marked); + Assert.Equal(("Running", "Interrupted", 0), ReadMarkers("running-1")); + } + + [Fact(DisplayName = "TryMarkInterruptedAsync promotes Finished/Cancelled to Running+Interrupted")] + public async Task TryMarkInterruptedAsync_MarksCancelledInstance() + { + InsertInterruptible("cancelled-1", WorkflowStatus.Finished, WorkflowSubStatus.Cancelled, isExecuting: false); + + using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + var marked = await _store.TryMarkInterruptedAsync("cancelled-1"); + + Assert.True(marked); + Assert.Equal(("Running", "Interrupted", 0), ReadMarkers("cancelled-1")); + } + + [Fact(DisplayName = "TryMarkInterruptedAsync refuses naturally completed Finished/Finished")] + public async Task TryMarkInterruptedAsync_RefusesFinishedInstance() + { + InsertInterruptible("finished-1", WorkflowStatus.Finished, WorkflowSubStatus.Finished, isExecuting: false); + + using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + var marked = await _store.TryMarkInterruptedAsync("finished-1"); + + Assert.False(marked); + Assert.Equal(("Finished", "Finished", 0), ReadMarkers("finished-1")); + } + + [Fact(DisplayName = "TryMarkInterruptedAsync refuses naturally completed Finished/Faulted")] + public async Task TryMarkInterruptedAsync_RefusesFaultedInstance() + { + InsertInterruptible("faulted-1", WorkflowStatus.Finished, WorkflowSubStatus.Faulted, isExecuting: false); + + using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + var marked = await _store.TryMarkInterruptedAsync("faulted-1"); + + Assert.False(marked); + Assert.Equal(("Finished", "Faulted", 0), ReadMarkers("faulted-1")); + } + + [Fact(DisplayName = "TryMarkInterruptedAsync returns false when the instance is missing")] + public async Task TryMarkInterruptedAsync_ReturnsFalseWhenMissing() + { + using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + var marked = await _store.TryMarkInterruptedAsync("missing-1"); + + Assert.False(marked); + } + public void Dispose() { File.Delete(_databasePath); } - private static void Insert(SqliteConnection connection, string id, DateTimeOffset updatedAt, bool isExecuting) + private void InsertInterruptible(string id, WorkflowStatus status, WorkflowSubStatus subStatus, bool isExecuting) + { + using var connection = new SqliteConnection(_connectionString); + connection.Open(); + Insert(connection, id, Cutoff.AddMinutes(-2), isExecuting, status.ToString(), subStatus.ToString()); + } + + private (string Status, string SubStatus, long IsExecuting) ReadMarkers(string id) + { + using var connection = new SqliteConnection(_connectionString); + return connection.QuerySingle<(string, string, long)>( + "select Status, SubStatus, IsExecuting from WorkflowInstances where Id = @Id", + new { Id = id }); + } + + private static void Insert(SqliteConnection connection, string id, DateTimeOffset updatedAt, bool isExecuting, string status = "Running", string subStatus = "Executing") { connection.Execute( """ insert into WorkflowInstances (Id, TenantId, DefinitionId, DefinitionVersionId, Version, WorkflowState, Status, SubStatus, IsExecuting, IncidentCount, IsSystem, CreatedAt, UpdatedAt) values - (@Id, 'tenant-a', 'definition-1', 'definition-1:1', 1, '{}', 'Running', 'Executing', @IsExecuting, 0, 0, @CreatedAt, @UpdatedAt); + (@Id, 'tenant-a', 'definition-1', 'definition-1:1', 1, '{}', @Status, @SubStatus, @IsExecuting, 0, 0, @CreatedAt, @UpdatedAt); """, new { Id = id, + Status = status, + SubStatus = subStatus, IsExecuting = isExecuting, CreatedAt = updatedAt, UpdatedAt = updatedAt diff --git a/test/modules/persistence/Elsa.Dapper.UnitTests/TryMarkInterruptedQueryTests.cs b/test/modules/persistence/Elsa.Dapper.UnitTests/TryMarkInterruptedQueryTests.cs index 56555fcc83..e90552863d 100644 --- a/test/modules/persistence/Elsa.Dapper.UnitTests/TryMarkInterruptedQueryTests.cs +++ b/test/modules/persistence/Elsa.Dapper.UnitTests/TryMarkInterruptedQueryTests.cs @@ -7,29 +7,34 @@ namespace Elsa.Dapper.UnitTests; public class TryMarkInterruptedQueryTests { - [Fact(DisplayName = "Conditional interrupt update targets non-terminal rows only")] - public void UpdateQuery_IncludesIdAndNonFinishedStatus() + [Fact(DisplayName = "Conditional interrupt update allows Running or Finished/Cancelled")] + public void UpdateQuery_AllowsRunningOrFinishedCancelled() { var record = new { Id = "running-1", + Status = WorkflowStatus.Running.ToString(), SubStatus = WorkflowSubStatus.Interrupted.ToString(), IsExecuting = false }; var query = new ParameterizedQuery(new SqliteDialect()) - .Update("WorkflowInstances", record, ["SubStatus", "IsExecuting"]) - .Is("Id", record.Id) - .IsNot("Status", WorkflowStatus.Finished.ToString()); + .Update("WorkflowInstances", record, ["Status", "SubStatus", "IsExecuting"]) + .Is("Id", record.Id); + query.Sql.AppendLine("and (not Status = @FinishedStatus or SubStatus = @CancelledSubStatus)"); + query.Parameters.Add("@FinishedStatus", WorkflowStatus.Finished.ToString()); + query.Parameters.Add("@CancelledSubStatus", WorkflowSubStatus.Cancelled.ToString()); var sql = query.Sql.ToString(); - Assert.Contains("UPDATE WorkflowInstances SET SubStatus = @SubStatus, IsExecuting = @IsExecuting WHERE 1=1", sql, StringComparison.Ordinal); + Assert.Contains("UPDATE WorkflowInstances SET Status = @Status, SubStatus = @SubStatus, IsExecuting = @IsExecuting WHERE 1=1", sql, StringComparison.Ordinal); Assert.Contains("and Id = @Id", sql, StringComparison.Ordinal); - Assert.Contains("and not Status = @Status", sql, StringComparison.Ordinal); + Assert.Contains("and (not Status = @FinishedStatus or SubStatus = @CancelledSubStatus)", sql, StringComparison.Ordinal); + Assert.Equal(WorkflowStatus.Running.ToString(), query.Parameters.Get("Status")); Assert.Equal(WorkflowSubStatus.Interrupted.ToString(), query.Parameters.Get("SubStatus")); Assert.False(query.Parameters.Get("IsExecuting")); Assert.Equal("running-1", query.Parameters.Get("Id")); - Assert.Equal(WorkflowStatus.Finished.ToString(), query.Parameters.Get("Status")); + Assert.Equal(WorkflowStatus.Finished.ToString(), query.Parameters.Get("FinishedStatus")); + Assert.Equal(WorkflowSubStatus.Cancelled.ToString(), query.Parameters.Get("CancelledSubStatus")); } } diff --git a/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoWorkflowInstanceStoreTests.cs b/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoWorkflowInstanceStoreTests.cs index 908d082bac..c398c62587 100644 --- a/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoWorkflowInstanceStoreTests.cs +++ b/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoWorkflowInstanceStoreTests.cs @@ -13,7 +13,7 @@ namespace Elsa.MongoDb.UnitTests; public class MongoWorkflowInstanceStoreTests { - [Fact(DisplayName = "TryMarkInterruptedAsync updates only when Status is not Finished")] + [Fact(DisplayName = "TryMarkInterruptedAsync updates Running instances to Running+Interrupted")] public async Task TryMarkInterruptedAsync_UpdatesNonTerminalInstance() { var (store, collection) = CreateStore(matchedCount: 1); @@ -22,14 +22,29 @@ public async Task TryMarkInterruptedAsync_UpdatesNonTerminalInstance() Assert.True(marked); await collection.Received(1).UpdateOneAsync( - Arg.Is>(filter => FilterMentionsIdAndNonFinished(filter, "running-1")), - Arg.Is>(update => UpdateSetsInterruptedMarkers(update)), + Arg.Is>(filter => FilterAllowsInterruptible(filter, "running-1")), + Arg.Is>(update => UpdatePromotesToRunningInterrupted(update)), Arg.Any(), Arg.Any()); } - [Fact(DisplayName = "TryMarkInterruptedAsync returns false when no non-terminal instance matches")] - public async Task TryMarkInterruptedAsync_ReturnsFalseWhenMissingOrFinished() + [Fact(DisplayName = "TryMarkInterruptedAsync filter allows Finished/Cancelled (drain force-cancel)")] + public async Task TryMarkInterruptedAsync_FilterAllowsFinishedCancelled() + { + var (store, collection) = CreateStore(matchedCount: 1); + + var marked = await store.TryMarkInterruptedAsync("cancelled-1"); + + Assert.True(marked); + await collection.Received(1).UpdateOneAsync( + Arg.Is>(filter => FilterAllowsInterruptible(filter, "cancelled-1")), + Arg.Is>(update => UpdatePromotesToRunningInterrupted(update)), + Arg.Any(), + Arg.Any()); + } + + [Fact(DisplayName = "TryMarkInterruptedAsync returns false when no interruptible instance matches")] + public async Task TryMarkInterruptedAsync_ReturnsFalseWhenMissingOrNaturallyCompleted() { var (store, _) = CreateStore(matchedCount: 0); @@ -58,19 +73,28 @@ private static (MongoWorkflowInstanceStore Store, IMongoCollection filter, string id) + /// + /// Id match plus the IsNaturallyCompleted inverse: Status != Finished OR SubStatus == Cancelled. + /// Refuses Finished/Finished and Finished/Faulted at the filter. + /// + private static bool FilterAllowsInterruptible(FilterDefinition filter, string id) { var rendered = Render(filter); return rendered.Contains(id, StringComparison.Ordinal) + && rendered.Contains("$or", StringComparison.Ordinal) && rendered.Contains("Status", StringComparison.Ordinal) && rendered.Contains("$ne", StringComparison.Ordinal) - && rendered.Contains(((int)WorkflowStatus.Finished).ToString(), StringComparison.Ordinal); + && rendered.Contains(((int)WorkflowStatus.Finished).ToString(), StringComparison.Ordinal) + && rendered.Contains("SubStatus", StringComparison.Ordinal) + && rendered.Contains(((int)WorkflowSubStatus.Cancelled).ToString(), StringComparison.Ordinal); } - private static bool UpdateSetsInterruptedMarkers(UpdateDefinition update) + private static bool UpdatePromotesToRunningInterrupted(UpdateDefinition update) { var rendered = Render(update); return rendered.Contains("$set", StringComparison.Ordinal) + && rendered.Contains("Status", StringComparison.Ordinal) + && rendered.Contains(((int)WorkflowStatus.Running).ToString(), StringComparison.Ordinal) && rendered.Contains("SubStatus", StringComparison.Ordinal) && rendered.Contains(((int)WorkflowSubStatus.Interrupted).ToString(), StringComparison.Ordinal) && rendered.Contains("IsExecuting", StringComparison.Ordinal); From 7a305107797d77e67ec751242170c3c70d4193d3 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sat, 12 Sep 2026 06:15:21 -0700 Subject: [PATCH 011/169] fix(ci): pack wasm-react against the local wasm tarball (#1030) * fix(ci): pack wasm-react against the local wasm tarball The wrappers workspace lockfile pins @elsa-workflows/elsa-studio-wasm to feedz 3.4.0. npm version in react-wrapper re-resolves that pin, which now 404s and skips all publish jobs. Install the just-packed $VERSION tgz instead so release packs do not depend on the stale feedz artifact. Co-authored-by: Sipke Schoorstra * fix(ci): allow Packages recovery of 3.8.1 without retagging The 3.8.1 tag still points at the broken pack workflow. Add optional workflow_dispatch version/publish inputs so release/3.8.1 can pack and publish 3.8.1 from this fix without moving the tag. Co-authored-by: Sipke Schoorstra * fix(ci): validate dispatch version before publish recovery Pass workflow_dispatch version through step env instead of interpolating it into bash, and reject anything that is not a semver-ish package version. Require that validated override whenever publish is true so a blank dispatch cannot ship 3.8.0-preview.N. Co-authored-by: Sipke Schoorstra * fix(ci): read dispatch version from env and require it to publish Pass inputs.version as $VERSION (step env), validate a simple semver-ish pattern before GITHUB_ENV, and fail if publish is set without a valid override. Publish job ifs still require a non-empty version. Co-authored-by: Sipke Schoorstra --------- Co-authored-by: Cursor Agent --- .github/workflows/packages.yml | 70 +++++++++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 10 deletions(-) diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 7186a5d228..8833a9d40b 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -1,6 +1,16 @@ name: Packages on: workflow_dispatch: + inputs: + version: + description: 'Optional package version override (semver-ish, e.g. 3.8.1 or 3.8.0-preview.1). Required when publish is checked. Leave empty for default branch/tag versioning.' + required: false + type: string + publish: + description: 'Publish packed artifacts (feedz, nuget.org, npmjs). Requires a non-empty version override so a blank dispatch cannot ship 3.8.0-preview.N.' + required: false + type: boolean + default: false push: branches: - 'main' @@ -60,13 +70,33 @@ jobs: git branch --remote --contains | grep origin/${BRANCH_NAME} fi - name: Set VERSION variable + env: + # Dispatcher input via env — do not interpolate inputs.version into bash. + VERSION: ${{ github.event.inputs.version }} + PUBLISH: ${{ github.event.inputs.publish }} run: | - if [[ "${{ github.ref }}" == refs/tags/* && "${{ github.event_name }}" == "release" && ("${{ github.event.action }}" == "published" || "${{ github.event.action }}" == "prereleased") ]]; then + set -euo pipefail + version_re='^[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9.-]+)?$' + + if [[ "${PUBLISH:-}" == "true" ]]; then + if [[ -z "${VERSION:-}" || ! "$VERSION" =~ $version_re ]]; then + echo "publish requires a valid version (e.g. 3.8.1). Refusing to ship ${{ env.base_version }}-preview.N" >&2 + exit 1 + fi + fi + + if [[ -n "${VERSION:-}" ]]; then + if [[ ! "$VERSION" =~ $version_re ]]; then + echo "Invalid version override. Expected a package version like 3.8.1 or 3.8.0-preview.1" >&2 + exit 1 + fi + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + elif [[ "${{ github.ref }}" == refs/tags/* && "${{ github.event_name }}" == "release" && ("${{ github.event.action }}" == "published" || "${{ github.event.action }}" == "prereleased") ]]; then TAG_NAME=${{ github.ref }} # e.g., refs/tags/3.0.0 TAG_NAME=${TAG_NAME#refs/tags/} # remove the refs/tags/ prefix - echo "VERSION=${TAG_NAME}" >> $GITHUB_ENV + echo "VERSION=${TAG_NAME}" >> "$GITHUB_ENV" else - echo "VERSION=${{env.base_version}}-${PACKAGE_PREFIX}.${{github.run_number}}" >> $GITHUB_ENV + echo "VERSION=${{ env.base_version }}-${PACKAGE_PREFIX}.${{ github.run_number }}" >> "$GITHUB_ENV" fi - uses: actions/setup-dotnet@v4 with: @@ -101,11 +131,31 @@ jobs: npm version $VERSION --allow-same-version npm pack - # Build npm react wrapper package + # Build npm react wrapper package against the just-packed local wasm tarball. + # react-wrapper lives in the src/wrappers npm workspace. `npm version` / + # `npm run build` there re-resolves package-lock.json, which is still pinned + # to @elsa-workflows/elsa-studio-wasm@3.4.0 on feedz. That tarball 404s + # (3.8.0 succeeded while feedz still served it). Install the $VERSION tgz + # produced by the previous step so pack never hits the registry for wasm. - name: Pack elsa-studio-wasm-react npm package run: | - cd ./src/wrappers/wrappers/react-wrapper - npm version $VERSION --allow-same-version + set -euo pipefail + WASM_TGZ="${GITHUB_WORKSPACE}/packages/wasm/wwwroot/elsa-workflows-elsa-studio-wasm-${VERSION}.tgz" + if [[ ! -f "$WASM_TGZ" ]]; then + echo "Expected local wasm tarball at $WASM_TGZ" >&2 + ls -la "${GITHUB_WORKSPACE}/packages/wasm/wwwroot" >&2 || true + exit 1 + fi + + cd ./src/wrappers + npm install "$WASM_TGZ" --workspace=@elsa-workflows/elsa-studio-wasm-react + + cd ./wrappers/react-wrapper + # Avoid `npm version` here: in this workspace it refreshes the lockfile + # and can re-fetch the stale feedz pin. Set the packed versions directly. + npm pkg set "version=${VERSION}" + npm pkg set "dependencies.@elsa-workflows/elsa-studio-wasm=${VERSION}" + npm run copy:elsa-studio-wasm npm run build npm pack @@ -135,7 +185,7 @@ jobs: needs: build runs-on: ubuntu-latest timeout-minutes: 10 - if: ${{ github.event_name == 'release' || github.event_name == 'push'}} + if: ${{ github.event_name == 'release' || github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish && inputs.version != '') }} steps: - name: Setup Node.js uses: actions/setup-node@v3 @@ -208,7 +258,7 @@ jobs: needs: build runs-on: ubuntu-latest timeout-minutes: 10 - if: ${{ github.event_name == 'release' && github.event.action == 'published' }} + if: ${{ (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.publish && inputs.version != '') }} permissions: contents: read id-token: write # Required: lets this job request the OIDC token npmjs.com exchanges for publish rights. @@ -281,7 +331,7 @@ jobs: needs: build runs-on: ubuntu-latest timeout-minutes: 10 - if: ${{ github.event_name == 'release' || github.event_name == 'push'}} + if: ${{ github.event_name == 'release' || github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish && inputs.version != '') }} steps: - name: Download Packages uses: actions/download-artifact@v4 @@ -297,7 +347,7 @@ jobs: needs: build runs-on: ubuntu-latest timeout-minutes: 10 - if: ${{ github.event_name == 'release' && github.event.action == 'published' }} + if: ${{ (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.publish && inputs.version != '') }} permissions: contents: read id-token: write # Required: lets this job request the OIDC token that nuget.org exchanges for a temporary API key. From c9f83e550c0e4652bbe7c64bd8e2713a88e9afa6 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sat, 12 Sep 2026 13:08:28 -0700 Subject: [PATCH 012/169] fix: gate Cancelled interrupt promote behind allowFinishedCancelled (#191) Mirror elsa-core#8069 (bc04b84): TryMarkInterruptedAsync refuses every Finished row by default and only promotes Finished/Cancelled when the drain-only flag is set. Keep Mongo UpdateOne, Dapper raw WHERE, and ES UpdateByQuery atomic shapes. Co-authored-by: Cursor Agent --- .../Stores/DapperWorkflowInstanceStore.cs | 20 ++++-- .../Management/WorkflowInstanceStore.cs | 55 +++++++++------ .../Management/WorkflowInstanceStore.cs | 20 +++--- .../DapperWorkflowInstanceStoreTests.cs | 44 ++++++++++-- .../TryMarkInterruptedQueryTests.cs | 53 +++++++++++---- .../MongoWorkflowInstanceStoreTests.cs | 67 ++++++++++++++++--- 6 files changed, 197 insertions(+), 62 deletions(-) diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowInstanceStore.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowInstanceStore.cs index 380549688d..de51fd8179 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowInstanceStore.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowInstanceStore.cs @@ -171,7 +171,7 @@ public async Task UpdateUpdatedTimestampAsync(string workflowInstanceId, DateTim } /// - public async ValueTask TryMarkInterruptedAsync(string workflowInstanceId, CancellationToken cancellationToken = default) + public async ValueTask TryMarkInterruptedAsync(string workflowInstanceId, CancellationToken cancellationToken = default, bool allowFinishedCancelled = false) { var record = new WorkflowInstanceRecord { @@ -187,13 +187,19 @@ public async ValueTask TryMarkInterruptedAsync(string workflowInstanceId, q => { q.Is(nameof(WorkflowInstanceRecord.Id), workflowInstanceId); - // Naturally completed rows (Finished && SubStatus != Cancelled) must not be - // interrupted. Finished/Cancelled is the runner's drain force-cancel commit - // and is interruptible (elsa-core#8069). Distinct param names avoid colliding - // with the SET Status = Running assignment. - q.Sql.AppendLine("and (not Status = @FinishedStatus or SubStatus = @CancelledSubStatus)"); + // Default: refuse every Finished row (#8052). Distinct param names avoid + // colliding with SET Status = Running. Drain PersistInterrupted alone may + // pass allowFinishedCancelled (elsa-core#8069). q.Parameters.Add("@FinishedStatus", WorkflowStatus.Finished.ToString()); - q.Parameters.Add("@CancelledSubStatus", WorkflowSubStatus.Cancelled.ToString()); + if (allowFinishedCancelled) + { + q.Sql.AppendLine("and (not Status = @FinishedStatus or SubStatus = @CancelledSubStatus)"); + q.Parameters.Add("@CancelledSubStatus", WorkflowSubStatus.Cancelled.ToString()); + } + else + { + q.Sql.AppendLine("and not Status = @FinishedStatus"); + } }, cancellationToken); diff --git a/src/modules/persistence/Elsa.Persistence.Elasticsearch/Modules/Management/WorkflowInstanceStore.cs b/src/modules/persistence/Elsa.Persistence.Elasticsearch/Modules/Management/WorkflowInstanceStore.cs index 6f96cfabbd..4d55fccbb0 100644 --- a/src/modules/persistence/Elsa.Persistence.Elasticsearch/Modules/Management/WorkflowInstanceStore.cs +++ b/src/modules/persistence/Elsa.Persistence.Elasticsearch/Modules/Management/WorkflowInstanceStore.cs @@ -151,32 +151,45 @@ public Task UpdateUpdatedTimestampAsync(string workflowInstanceId, DateTimeOffse } /// - public async ValueTask TryMarkInterruptedAsync(string workflowInstanceId, CancellationToken cancellationToken = default) + public async ValueTask TryMarkInterruptedAsync(string workflowInstanceId, CancellationToken cancellationToken = default, bool allowFinishedCancelled = false) { // Conditional write: do not SaveAsync a Find snapshot. Id is a document field - // (not mapped as _id), so Update-by-id is unavailable. UpdateByQuery applies - // the same interruptible predicate as EF/Mongo (elsa-core#8069): - // Status != Finished OR SubStatus == Cancelled. - var updated = await _store.UpdateByQueryAsync(d => d - .Refresh(true) - .Query(q => q.Bool(b => b - .Must( - m => m.Match(mt => mt.Field(f => f.Id).Query(workflowInstanceId)), - m => m.Bool(s => s - .Should( - sh => sh.Bool(n => n.MustNot(mn => mn.Match(mt => mt - .Field(f => f.Status) - .Query(WorkflowStatus.Finished.ToString()!)))), - sh => sh.Match(mt => mt - .Field(f => f.SubStatus) - .Query(WorkflowSubStatus.Cancelled.ToString()!))) - .MinimumShouldMatch(1))))) - .Script(s => s + // (not mapped as _id), so Update-by-id is unavailable. Default UpdateByQuery + // refuses every Finished row (#8052). Drain PersistInterrupted alone may pass + // allowFinishedCancelled to add Status != Finished OR SubStatus == Cancelled. + var updated = await _store.UpdateByQueryAsync(d => + { + d.Refresh(true); + if (allowFinishedCancelled) + { + d.Query(q => q.Bool(b => b + .Must( + m => m.Match(mt => mt.Field(f => f.Id).Query(workflowInstanceId)), + m => m.Bool(s => s + .Should( + sh => sh.Bool(n => n.MustNot(mn => mn.Match(mt => mt + .Field(f => f.Status) + .Query(WorkflowStatus.Finished.ToString()!)))), + sh => sh.Match(mt => mt + .Field(f => f.SubStatus) + .Query(WorkflowSubStatus.Cancelled.ToString()!))) + .MinimumShouldMatch(1))))); + } + else + { + d.Query(q => q.Bool(b => b + .Must(m => m.Match(mt => mt.Field(f => f.Id).Query(workflowInstanceId))) + .MustNot(mn => mn.Match(mt => mt + .Field(f => f.Status) + .Query(WorkflowStatus.Finished.ToString()!))))); + } + + d.Script(s => s .Source("ctx._source.status = params.status; ctx._source.subStatus = params.subStatus; ctx._source.isExecuting = params.isExecuting;") .AddParam("status", WorkflowStatus.Running.ToString()) .AddParam("subStatus", WorkflowSubStatus.Interrupted.ToString()) - .AddParam("isExecuting", false)), - cancellationToken); + .AddParam("isExecuting", false)); + }, cancellationToken); return updated > 0; } diff --git a/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowInstanceStore.cs b/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowInstanceStore.cs index 43b262844c..5e96c0df08 100644 --- a/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowInstanceStore.cs +++ b/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowInstanceStore.cs @@ -139,16 +139,20 @@ public async Task UpdateUpdatedTimestampAsync(string workflowInstanceId, DateTim } /// - public async ValueTask TryMarkInterruptedAsync(string workflowInstanceId, CancellationToken cancellationToken = default) + public async ValueTask TryMarkInterruptedAsync(string workflowInstanceId, CancellationToken cancellationToken = default, bool allowFinishedCancelled = false) { var collection = mongoDbStore.GetCollection(); - // Interruptible = not naturally completed. Finished/Cancelled is the runner's commit - // after drain force-cancel and must become Running+Interrupted (elsa-core#8069). - var filter = Builders.Filter.And( - Builders.Filter.Eq(x => x.Id, workflowInstanceId), - Builders.Filter.Or( - Builders.Filter.Ne(x => x.Status, WorkflowStatus.Finished), - Builders.Filter.Eq(x => x.SubStatus, WorkflowSubStatus.Cancelled))); + // Default: refuse every Finished row (#8052). Drain PersistInterrupted alone may pass + // allowFinishedCancelled to promote Finished/Cancelled → Running+Interrupted. + var idFilter = Builders.Filter.Eq(x => x.Id, workflowInstanceId); + var notFinished = Builders.Filter.Ne(x => x.Status, WorkflowStatus.Finished); + var filter = allowFinishedCancelled + ? Builders.Filter.And( + idFilter, + Builders.Filter.Or( + notFinished, + Builders.Filter.Eq(x => x.SubStatus, WorkflowSubStatus.Cancelled))) + : Builders.Filter.And(idFilter, notFinished); var update = Builders.Update .Set(x => x.Status, WorkflowStatus.Running) .Set(x => x.SubStatus, WorkflowSubStatus.Interrupted) diff --git a/test/modules/persistence/Elsa.Dapper.UnitTests/DapperWorkflowInstanceStoreTests.cs b/test/modules/persistence/Elsa.Dapper.UnitTests/DapperWorkflowInstanceStoreTests.cs index 876ef68b27..703de298cf 100644 --- a/test/modules/persistence/Elsa.Dapper.UnitTests/DapperWorkflowInstanceStoreTests.cs +++ b/test/modules/persistence/Elsa.Dapper.UnitTests/DapperWorkflowInstanceStoreTests.cs @@ -118,19 +118,31 @@ public async Task TryMarkInterruptedAsync_MarksRunningInstance() Assert.Equal(("Running", "Interrupted", 0), ReadMarkers("running-1")); } - [Fact(DisplayName = "TryMarkInterruptedAsync promotes Finished/Cancelled to Running+Interrupted")] - public async Task TryMarkInterruptedAsync_MarksCancelledInstance() + [Fact(DisplayName = "TryMarkInterruptedAsync refuses Finished/Cancelled unless allowFinishedCancelled is set")] + public async Task TryMarkInterruptedAsync_DoesNotOverwriteCancelledInstanceByDefault() { InsertInterruptible("cancelled-1", WorkflowStatus.Finished, WorkflowSubStatus.Cancelled, isExecuting: false); using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); var marked = await _store.TryMarkInterruptedAsync("cancelled-1"); + Assert.False(marked); + Assert.Equal(("Finished", "Cancelled", 0), ReadMarkers("cancelled-1")); + } + + [Fact(DisplayName = "TryMarkInterruptedAsync promotes Finished/Cancelled only when allowFinishedCancelled is true")] + public async Task TryMarkInterruptedAsync_PromotesCancelledWhenAllowed() + { + InsertInterruptible("cancelled-1", WorkflowStatus.Finished, WorkflowSubStatus.Cancelled, isExecuting: false); + + using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + var marked = await _store.TryMarkInterruptedAsync("cancelled-1", allowFinishedCancelled: true); + Assert.True(marked); Assert.Equal(("Running", "Interrupted", 0), ReadMarkers("cancelled-1")); } - [Fact(DisplayName = "TryMarkInterruptedAsync refuses naturally completed Finished/Finished")] + [Fact(DisplayName = "TryMarkInterruptedAsync refuses Finished/Finished")] public async Task TryMarkInterruptedAsync_RefusesFinishedInstance() { InsertInterruptible("finished-1", WorkflowStatus.Finished, WorkflowSubStatus.Finished, isExecuting: false); @@ -142,7 +154,19 @@ public async Task TryMarkInterruptedAsync_RefusesFinishedInstance() Assert.Equal(("Finished", "Finished", 0), ReadMarkers("finished-1")); } - [Fact(DisplayName = "TryMarkInterruptedAsync refuses naturally completed Finished/Faulted")] + [Fact(DisplayName = "TryMarkInterruptedAsync still refuses Finished/Finished when allowFinishedCancelled is true")] + public async Task TryMarkInterruptedAsync_RefusesFinishedEvenWhenCancelledAllowed() + { + InsertInterruptible("finished-1", WorkflowStatus.Finished, WorkflowSubStatus.Finished, isExecuting: false); + + using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + var marked = await _store.TryMarkInterruptedAsync("finished-1", allowFinishedCancelled: true); + + Assert.False(marked); + Assert.Equal(("Finished", "Finished", 0), ReadMarkers("finished-1")); + } + + [Fact(DisplayName = "TryMarkInterruptedAsync refuses Finished/Faulted")] public async Task TryMarkInterruptedAsync_RefusesFaultedInstance() { InsertInterruptible("faulted-1", WorkflowStatus.Finished, WorkflowSubStatus.Faulted, isExecuting: false); @@ -154,6 +178,18 @@ public async Task TryMarkInterruptedAsync_RefusesFaultedInstance() Assert.Equal(("Finished", "Faulted", 0), ReadMarkers("faulted-1")); } + [Fact(DisplayName = "TryMarkInterruptedAsync still refuses Finished/Faulted when allowFinishedCancelled is true")] + public async Task TryMarkInterruptedAsync_RefusesFaultedEvenWhenCancelledAllowed() + { + InsertInterruptible("faulted-1", WorkflowStatus.Finished, WorkflowSubStatus.Faulted, isExecuting: false); + + using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + var marked = await _store.TryMarkInterruptedAsync("faulted-1", allowFinishedCancelled: true); + + Assert.False(marked); + Assert.Equal(("Finished", "Faulted", 0), ReadMarkers("faulted-1")); + } + [Fact(DisplayName = "TryMarkInterruptedAsync returns false when the instance is missing")] public async Task TryMarkInterruptedAsync_ReturnsFalseWhenMissing() { diff --git a/test/modules/persistence/Elsa.Dapper.UnitTests/TryMarkInterruptedQueryTests.cs b/test/modules/persistence/Elsa.Dapper.UnitTests/TryMarkInterruptedQueryTests.cs index e90552863d..d46b06009e 100644 --- a/test/modules/persistence/Elsa.Dapper.UnitTests/TryMarkInterruptedQueryTests.cs +++ b/test/modules/persistence/Elsa.Dapper.UnitTests/TryMarkInterruptedQueryTests.cs @@ -7,8 +7,42 @@ namespace Elsa.Dapper.UnitTests; public class TryMarkInterruptedQueryTests { - [Fact(DisplayName = "Conditional interrupt update allows Running or Finished/Cancelled")] - public void UpdateQuery_AllowsRunningOrFinishedCancelled() + [Fact(DisplayName = "Default interrupt update refuses every Finished row")] + public void UpdateQuery_RefusesFinishedByDefault() + { + var query = CreateInterruptUpdate(); + query.Sql.AppendLine("and not Status = @FinishedStatus"); + query.Parameters.Add("@FinishedStatus", WorkflowStatus.Finished.ToString()); + + var sql = query.Sql.ToString(); + + Assert.Contains("UPDATE WorkflowInstances SET Status = @Status, SubStatus = @SubStatus, IsExecuting = @IsExecuting WHERE 1=1", sql, StringComparison.Ordinal); + Assert.Contains("and Id = @Id", sql, StringComparison.Ordinal); + Assert.Contains("and not Status = @FinishedStatus", sql, StringComparison.Ordinal); + Assert.DoesNotContain("CancelledSubStatus", sql, StringComparison.Ordinal); + AssertSharedInterruptParameters(query); + Assert.Equal(WorkflowStatus.Finished.ToString(), query.Parameters.Get("FinishedStatus")); + } + + [Fact(DisplayName = "Conditional interrupt update allows Running or Finished/Cancelled when flag is set")] + public void UpdateQuery_AllowsRunningOrFinishedCancelledWhenFlagSet() + { + var query = CreateInterruptUpdate(); + query.Sql.AppendLine("and (not Status = @FinishedStatus or SubStatus = @CancelledSubStatus)"); + query.Parameters.Add("@FinishedStatus", WorkflowStatus.Finished.ToString()); + query.Parameters.Add("@CancelledSubStatus", WorkflowSubStatus.Cancelled.ToString()); + + var sql = query.Sql.ToString(); + + Assert.Contains("UPDATE WorkflowInstances SET Status = @Status, SubStatus = @SubStatus, IsExecuting = @IsExecuting WHERE 1=1", sql, StringComparison.Ordinal); + Assert.Contains("and Id = @Id", sql, StringComparison.Ordinal); + Assert.Contains("and (not Status = @FinishedStatus or SubStatus = @CancelledSubStatus)", sql, StringComparison.Ordinal); + AssertSharedInterruptParameters(query); + Assert.Equal(WorkflowStatus.Finished.ToString(), query.Parameters.Get("FinishedStatus")); + Assert.Equal(WorkflowSubStatus.Cancelled.ToString(), query.Parameters.Get("CancelledSubStatus")); + } + + private static ParameterizedQuery CreateInterruptUpdate() { var record = new { @@ -18,23 +52,16 @@ public void UpdateQuery_AllowsRunningOrFinishedCancelled() IsExecuting = false }; - var query = new ParameterizedQuery(new SqliteDialect()) + return new ParameterizedQuery(new SqliteDialect()) .Update("WorkflowInstances", record, ["Status", "SubStatus", "IsExecuting"]) .Is("Id", record.Id); - query.Sql.AppendLine("and (not Status = @FinishedStatus or SubStatus = @CancelledSubStatus)"); - query.Parameters.Add("@FinishedStatus", WorkflowStatus.Finished.ToString()); - query.Parameters.Add("@CancelledSubStatus", WorkflowSubStatus.Cancelled.ToString()); - - var sql = query.Sql.ToString(); + } - Assert.Contains("UPDATE WorkflowInstances SET Status = @Status, SubStatus = @SubStatus, IsExecuting = @IsExecuting WHERE 1=1", sql, StringComparison.Ordinal); - Assert.Contains("and Id = @Id", sql, StringComparison.Ordinal); - Assert.Contains("and (not Status = @FinishedStatus or SubStatus = @CancelledSubStatus)", sql, StringComparison.Ordinal); + private static void AssertSharedInterruptParameters(ParameterizedQuery query) + { Assert.Equal(WorkflowStatus.Running.ToString(), query.Parameters.Get("Status")); Assert.Equal(WorkflowSubStatus.Interrupted.ToString(), query.Parameters.Get("SubStatus")); Assert.False(query.Parameters.Get("IsExecuting")); Assert.Equal("running-1", query.Parameters.Get("Id")); - Assert.Equal(WorkflowStatus.Finished.ToString(), query.Parameters.Get("FinishedStatus")); - Assert.Equal(WorkflowSubStatus.Cancelled.ToString(), query.Parameters.Get("CancelledSubStatus")); } } diff --git a/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoWorkflowInstanceStoreTests.cs b/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoWorkflowInstanceStoreTests.cs index c398c62587..17c3d914f8 100644 --- a/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoWorkflowInstanceStoreTests.cs +++ b/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoWorkflowInstanceStoreTests.cs @@ -22,29 +22,64 @@ public async Task TryMarkInterruptedAsync_UpdatesNonTerminalInstance() Assert.True(marked); await collection.Received(1).UpdateOneAsync( - Arg.Is>(filter => FilterAllowsInterruptible(filter, "running-1")), + Arg.Is>(filter => FilterRefusesFinished(filter, "running-1")), Arg.Is>(update => UpdatePromotesToRunningInterrupted(update)), Arg.Any(), Arg.Any()); } - [Fact(DisplayName = "TryMarkInterruptedAsync filter allows Finished/Cancelled (drain force-cancel)")] - public async Task TryMarkInterruptedAsync_FilterAllowsFinishedCancelled() + [Fact(DisplayName = "TryMarkInterruptedAsync refuses Finished/Cancelled unless allowFinishedCancelled is set")] + public async Task TryMarkInterruptedAsync_FilterRefusesFinishedCancelledByDefault() { - var (store, collection) = CreateStore(matchedCount: 1); + var (store, collection) = CreateStore(matchedCount: 0); var marked = await store.TryMarkInterruptedAsync("cancelled-1"); + Assert.False(marked); + await collection.Received(1).UpdateOneAsync( + Arg.Is>(filter => FilterRefusesFinished(filter, "cancelled-1")), + Arg.Is>(update => UpdatePromotesToRunningInterrupted(update)), + Arg.Any(), + Arg.Any()); + } + + [Fact(DisplayName = "TryMarkInterruptedAsync filter allows Finished/Cancelled only when allowFinishedCancelled is true")] + public async Task TryMarkInterruptedAsync_FilterAllowsFinishedCancelledWhenFlagSet() + { + var (store, collection) = CreateStore(matchedCount: 1); + + var marked = await store.TryMarkInterruptedAsync("cancelled-1", allowFinishedCancelled: true); + Assert.True(marked); await collection.Received(1).UpdateOneAsync( - Arg.Is>(filter => FilterAllowsInterruptible(filter, "cancelled-1")), + Arg.Is>(filter => FilterAllowsFinishedCancelled(filter, "cancelled-1")), Arg.Is>(update => UpdatePromotesToRunningInterrupted(update)), Arg.Any(), Arg.Any()); } + [Fact(DisplayName = "TryMarkInterruptedAsync still refuses Finished/Finished when allowFinishedCancelled is true")] + public async Task TryMarkInterruptedAsync_RefusesFinishedEvenWhenCancelledAllowed() + { + var (store, _) = CreateStore(matchedCount: 0); + + var marked = await store.TryMarkInterruptedAsync("finished-1", allowFinishedCancelled: true); + + Assert.False(marked); + } + + [Fact(DisplayName = "TryMarkInterruptedAsync still refuses Finished/Faulted when allowFinishedCancelled is true")] + public async Task TryMarkInterruptedAsync_RefusesFaultedEvenWhenCancelledAllowed() + { + var (store, _) = CreateStore(matchedCount: 0); + + var marked = await store.TryMarkInterruptedAsync("faulted-1", allowFinishedCancelled: true); + + Assert.False(marked); + } + [Fact(DisplayName = "TryMarkInterruptedAsync returns false when no interruptible instance matches")] - public async Task TryMarkInterruptedAsync_ReturnsFalseWhenMissingOrNaturallyCompleted() + public async Task TryMarkInterruptedAsync_ReturnsFalseWhenMissingOrFinished() { var (store, _) = CreateStore(matchedCount: 0); @@ -74,10 +109,24 @@ private static (MongoWorkflowInstanceStore Store, IMongoCollection - /// Id match plus the IsNaturallyCompleted inverse: Status != Finished OR SubStatus == Cancelled. - /// Refuses Finished/Finished and Finished/Faulted at the filter. + /// Default filter: Id match and Status != Finished. No store-wide Cancelled OR. + /// + private static bool FilterRefusesFinished(FilterDefinition filter, string id) + { + var rendered = Render(filter); + return rendered.Contains(id, StringComparison.Ordinal) + && rendered.Contains("Status", StringComparison.Ordinal) + && rendered.Contains("$ne", StringComparison.Ordinal) + && rendered.Contains(((int)WorkflowStatus.Finished).ToString(), StringComparison.Ordinal) + && !rendered.Contains("$or", StringComparison.Ordinal) + && !rendered.Contains(((int)WorkflowSubStatus.Cancelled).ToString(), StringComparison.Ordinal); + } + + /// + /// Flag-true filter: Id match plus Status != Finished OR SubStatus == Cancelled. + /// Still refuses Finished/Finished and Finished/Faulted at the filter. /// - private static bool FilterAllowsInterruptible(FilterDefinition filter, string id) + private static bool FilterAllowsFinishedCancelled(FilterDefinition filter, string id) { var rendered = Render(filter); return rendered.Contains(id, StringComparison.Ordinal) From 99de962e49f7793011196667659ac0cdb8df3fb7 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sat, 12 Sep 2026 15:32:29 -0700 Subject: [PATCH 013/169] chore: bump Elsa.Api.Client to 3.8.1 (#1035) * chore: bump Elsa.Api.Client to 3.8.1 Align Studio's central Elsa.Api.Client pin with nuget.org core 3.8.1 so Packages can republish against that release. Co-authored-by: Sipke Schoorstra * fix(ci): reject extra-dot version overrides in packages.yml Tighten Set VERSION to hyphen-led SemVer only so 3.8.1 and 3.8.0-preview.1 pass, but 3.8.1.foo is rejected before npm pack. Co-authored-by: Sipke Schoorstra --------- Co-authored-by: Cursor Agent --- .github/workflows/packages.yml | 2 +- Directory.Packages.props | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 8833a9d40b..336d1f0ccd 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -76,7 +76,7 @@ jobs: PUBLISH: ${{ github.event.inputs.publish }} run: | set -euo pipefail - version_re='^[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9.-]+)?$' + version_re='^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$' if [[ "${PUBLISH:-}" == "true" ]]; then if [[ -z "${VERSION:-}" || ! "$VERSION" =~ $version_re ]]; then diff --git a/Directory.Packages.props b/Directory.Packages.props index a870fec074..70699d3d21 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,7 +4,7 @@ false - + From 0619be93b67dbfd933e148663eeffb657e52f69c Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sat, 12 Sep 2026 15:54:01 -0700 Subject: [PATCH 014/169] chore: bump ElsaVersion and ElsaStudioVersion to 3.8.1 (#192) Retarget central Elsa.* / Elsa.Studio.* package pins so extensions can republish against core and studio 3.8.1. Directory.Packages.props already consumes these properties. Co-authored-by: Cursor Agent --- Directory.Build.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index cbd95f6379..a5a687a877 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -37,8 +37,8 @@ - 3.8.0 - 3.8.0 + 3.8.1 + 3.8.1 From 249d0f077f8a0b5e6cb1a42b02b49f2f636b5ab5 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Mon, 14 Sep 2026 05:32:55 +0200 Subject: [PATCH 015/169] fix(keyvalues): tenant-isolate MemoryKeyValueStore (#8141) Closes #8104. MemoryKeyValueStore: ambient tenant stamp, TenantVisibility on Find/FindMany, tenant-scoped DeleteAsync. TenantVisibility helpers backported for 3.8.1. --- doc/wiki/persistence.md | 4 + .../Multitenancy/TenantVisibility.cs | 35 +++ .../Elsa.Common/Services/MemoryStore.cs | 7 + .../Stores/MemoryKeyValueStore.cs | 43 +++- .../Multitenancy/TenantVisibilityTests.cs | 79 +++++++ ...MemoryKeyValueStoreTenantIsolationTests.cs | 200 ++++++++++++++++++ 6 files changed, 362 insertions(+), 6 deletions(-) create mode 100644 src/modules/Elsa.Common/Multitenancy/TenantVisibility.cs create mode 100644 test/unit/Elsa.Common.UnitTests/Multitenancy/TenantVisibilityTests.cs create mode 100644 test/unit/Elsa.Workflows.Runtime.UnitTests/Services/MemoryKeyValueStoreTenantIsolationTests.cs diff --git a/doc/wiki/persistence.md b/doc/wiki/persistence.md index 3ce279c0b6..b2d5835550 100644 --- a/doc/wiki/persistence.md +++ b/doc/wiki/persistence.md @@ -92,6 +92,10 @@ EF Core persistence decorates `IDbContextFactory` with `TenantAwareD - `ApplyTenantId` - `SetTenantIdFilter` +Default Memory stores must apply the same admission rule through `TenantVisibility` (ambient match, `*`, null-on-default-tenant) and stamp the ambient tenant on save when unset. + +`SerializedKeyValuePair` is keyed by `Id` (= `Key`) alone. `TenantId` is a filter/index, not part of the primary key, so two tenants cannot persist the same key under EF (PK collision). That is intentional for cluster-wide keys (heartbeat, quiescence): callers that need tenant-scoped names must encode the tenant into the key. A composite `(TenantId, Key)` identity is out of scope. Memory still stamps, filters, and tenant-scopes delete so a non-owning tenant cannot read or wipe another tenant's row. + Tenant conventions are documented in ADRs: - [ADR 0008: Empty String As Default Tenant ID](../adr/0008-empty-string-as-default-tenant-id.md) diff --git a/src/modules/Elsa.Common/Multitenancy/TenantVisibility.cs b/src/modules/Elsa.Common/Multitenancy/TenantVisibility.cs new file mode 100644 index 0000000000..a6b6023677 --- /dev/null +++ b/src/modules/Elsa.Common/Multitenancy/TenantVisibility.cs @@ -0,0 +1,35 @@ +using Elsa.Common.Entities; + +namespace Elsa.Common.Multitenancy; + +/// +/// Memory-store counterpart of EF Core SetTenantIdFilter. +/// A row is visible when its matches the ambient tenant, +/// is , or is null while the ambient tenant is the default. +/// +public static class TenantVisibility +{ + /// + /// Returns whether is visible under . + /// + public static bool IsVisible(string? entityTenantId, string ambientTenantId) => + entityTenantId == ambientTenantId + || entityTenantId == Tenant.AgnosticTenantId + || entityTenantId is null && ambientTenantId == Tenant.DefaultTenantId; + + /// + /// Restricts to rows visible to , + /// unless is set (EF IgnoreQueryFilters). + /// + public static IQueryable WhereVisibleToTenant(this IQueryable queryable, string ambientTenantId, bool tenantAgnostic = false) + where T : Entity + { + if (tenantAgnostic) + return queryable; + + return queryable.Where(entity => + entity.TenantId == ambientTenantId + || entity.TenantId == Tenant.AgnosticTenantId + || entity.TenantId == null && ambientTenantId == Tenant.DefaultTenantId); + } +} diff --git a/src/modules/Elsa.Common/Services/MemoryStore.cs b/src/modules/Elsa.Common/Services/MemoryStore.cs index cbbb7f84f3..a47590809f 100644 --- a/src/modules/Elsa.Common/Services/MemoryStore.cs +++ b/src/modules/Elsa.Common/Services/MemoryStore.cs @@ -10,6 +10,13 @@ namespace Elsa.Common.Services; public class MemoryStore { private IDictionary Entities { get; set; } = new ConcurrentDictionary(); + + /// + /// One lock for every scoped wrapper that shares this store. Compare-and-swap + /// load/match/write and the other Save/Delete paths take it so two scopes cannot + /// race on the same in-memory set. + /// + public object Sync { get; } = new(); /// /// Gets a queryable of all entities. diff --git a/src/modules/Elsa.KeyValues/Stores/MemoryKeyValueStore.cs b/src/modules/Elsa.KeyValues/Stores/MemoryKeyValueStore.cs index 9e0ba2443a..704e8a200b 100644 --- a/src/modules/Elsa.KeyValues/Stores/MemoryKeyValueStore.cs +++ b/src/modules/Elsa.KeyValues/Stores/MemoryKeyValueStore.cs @@ -1,3 +1,5 @@ +using Elsa.Common.Entities; +using Elsa.Common.Multitenancy; using Elsa.Common.Services; using Elsa.KeyValues.Contracts; using Elsa.KeyValues.Entities; @@ -8,43 +10,72 @@ namespace Elsa.KeyValues.Stores; /// /// Stores key value records in memory. /// +/// +/// Ambient tenant is applied here rather than in callers. +/// EF owns that via SetTenantIdFilter / ApplyTenantId; Memory must compensate. +/// Key-value contracts have no TenantAgnostic flag, so isolation always applies (EF query filter). +/// is keyed by Id (= Key) alone; TenantId +/// is a filter, not part of the primary key. Callers that need tenant-scoped names must encode +/// the tenant into the key. A composite (TenantId, Key) identity is out of scope. +/// public class MemoryKeyValueStore : IKeyValueStore { private readonly MemoryStore _store; + private readonly ITenantAccessor? _tenantAccessor; /// /// Initializes a new instance of the class. /// - public MemoryKeyValueStore(MemoryStore store) + public MemoryKeyValueStore(MemoryStore store, ITenantAccessor? tenantAccessor = null) { _store = store; + _tenantAccessor = tenantAccessor; } /// public Task SaveAsync(SerializedKeyValuePair keyValuePair, CancellationToken cancellationToken) { - _store.Save(keyValuePair, kv => kv.Id); + lock (_store.Sync) + { + ApplyCurrentTenant(keyValuePair); + _store.Save(keyValuePair, kv => kv.Id); + } + return Task.CompletedTask; } /// public Task FindAsync(KeyValueFilter filter, CancellationToken cancellationToken) { - var result = _store.Query(filter.Apply).FirstOrDefault(); + var result = _store.Query(query => filter.Apply(query.WhereVisibleToTenant(CurrentTenantId))).FirstOrDefault(); return Task.FromResult(result); } /// public Task> FindManyAsync(KeyValueFilter filter, CancellationToken cancellationToken) { - var result = _store.Query(filter.Apply); + var result = _store.Query(query => filter.Apply(query.WhereVisibleToTenant(CurrentTenantId))); return Task.FromResult(result); } /// public Task DeleteAsync(string key, CancellationToken cancellationToken) { - _store.DeleteWhere(x => x.Key == key); + lock (_store.Sync) + _store.DeleteWhere(x => x.Key == key && IsVisible(x)); + return Task.CompletedTask; } -} \ No newline at end of file + + private bool IsVisible(Entity entity) => TenantVisibility.IsVisible(entity.TenantId, CurrentTenantId); + + private string CurrentTenantId => _tenantAccessor?.TenantId ?? Tenant.DefaultTenantId; + + private void ApplyCurrentTenant(Entity entity) + { + if (entity.TenantId == Tenant.AgnosticTenantId || _tenantAccessor is null) + return; + + entity.TenantId ??= _tenantAccessor.TenantId; + } +} diff --git a/test/unit/Elsa.Common.UnitTests/Multitenancy/TenantVisibilityTests.cs b/test/unit/Elsa.Common.UnitTests/Multitenancy/TenantVisibilityTests.cs new file mode 100644 index 0000000000..07ff5b9587 --- /dev/null +++ b/test/unit/Elsa.Common.UnitTests/Multitenancy/TenantVisibilityTests.cs @@ -0,0 +1,79 @@ +using Elsa.Common.Entities; +using Elsa.Common.Multitenancy; + +namespace Elsa.Common.UnitTests.Multitenancy; + +/// +/// Mirrors EF SetTenantIdFilter: ambient match, *, and null-on-default-tenant. +/// +public class TenantVisibilityTests +{ + [Theory] + [InlineData("tenant-a", "tenant-a", true)] + [InlineData("tenant-a", "tenant-b", false)] + [InlineData(Tenant.AgnosticTenantId, "tenant-a", true)] + [InlineData(Tenant.AgnosticTenantId, Tenant.DefaultTenantId, true)] + [InlineData(null, Tenant.DefaultTenantId, true)] + [InlineData(null, "tenant-a", false)] + [InlineData(Tenant.DefaultTenantId, Tenant.DefaultTenantId, true)] + [InlineData(Tenant.DefaultTenantId, "tenant-a", false)] + public void IsVisible_MatchesSetTenantIdFilter(string? entityTenantId, string ambientTenantId, bool expected) + { + Assert.Equal(expected, TenantVisibility.IsVisible(entityTenantId, ambientTenantId)); + } + + [Fact] + public void WhereVisibleToTenant_WhenNotAgnostic_HidesOtherTenants() + { + var queryable = new[] + { + Entity("tenant-a"), + Entity("tenant-b"), + Entity(Tenant.AgnosticTenantId), + Entity(null) + }.AsQueryable(); + + var visible = queryable.WhereVisibleToTenant("tenant-a").Select(x => x.TenantId).ToList(); + + Assert.Equal(2, visible.Count); + Assert.Contains("tenant-a", visible); + Assert.Contains(Tenant.AgnosticTenantId, visible); + } + + [Fact] + public void WhereVisibleToTenant_WhenAgnostic_ReturnsAllRows() + { + var queryable = new[] + { + Entity("tenant-a"), + Entity("tenant-b") + }.AsQueryable(); + + var visible = queryable.WhereVisibleToTenant("tenant-a", tenantAgnostic: true).ToList(); + + Assert.Equal(2, visible.Count); + } + + [Fact] + public void WhereVisibleToTenant_WhenAmbientIsDefault_IncludesNullTenantId() + { + var queryable = new[] + { + Entity(null), + Entity("tenant-a") + }.AsQueryable(); + + var visible = queryable.WhereVisibleToTenant(Tenant.DefaultTenantId).ToList(); + + Assert.Single(visible); + Assert.Null(visible[0].TenantId); + } + + private static TestEntity Entity(string? tenantId) => new() + { + Id = tenantId ?? "null", + TenantId = tenantId + }; + + private sealed class TestEntity : Entity; +} diff --git a/test/unit/Elsa.Workflows.Runtime.UnitTests/Services/MemoryKeyValueStoreTenantIsolationTests.cs b/test/unit/Elsa.Workflows.Runtime.UnitTests/Services/MemoryKeyValueStoreTenantIsolationTests.cs new file mode 100644 index 0000000000..3342d3cea7 --- /dev/null +++ b/test/unit/Elsa.Workflows.Runtime.UnitTests/Services/MemoryKeyValueStoreTenantIsolationTests.cs @@ -0,0 +1,200 @@ +using Elsa.Common.Multitenancy; +using Elsa.Common.Services; +using Elsa.KeyValues.Entities; +using Elsa.KeyValues.Models; +using Elsa.KeyValues.Stores; + +namespace Elsa.Workflows.Runtime.UnitTests.Services; + +/// +/// Memory KeyValue must honor ambient tenant the same way EF does via +/// SetTenantIdFilter / ApplyTenantId. Contracts have no TenantAgnostic flag. +/// +public class MemoryKeyValueStoreTenantIsolationTests +{ + [Fact(DisplayName = "FindAsync does not return another tenant's row")] + public async Task FindAsync_WhenOtherTenant_ReturnsNull() + { + var store = CreateStore("tenant-a"); + await SeedMixedTenantsAsync(store); + + var found = await store.FindAsync(new KeyValueFilter { Key = "kv-b" }, CancellationToken.None); + + Assert.Null(found); + } + + [Fact(DisplayName = "FindAsync returns the ambient tenant's row and keeps * visible")] + public async Task FindAsync_ReturnsOwnAndAgnosticRows() + { + var store = CreateStore("tenant-a"); + await SeedMixedTenantsAsync(store); + + var own = await store.FindAsync(new KeyValueFilter { Key = "kv-a" }, CancellationToken.None); + var agnostic = await store.FindAsync(new KeyValueFilter { Key = "kv-star" }, CancellationToken.None); + + Assert.NotNull(own); + Assert.Equal("a", own.SerializedValue); + Assert.NotNull(agnostic); + Assert.Equal("star", agnostic.SerializedValue); + } + + [Fact(DisplayName = "FindManyAsync hides other tenants and keeps * visible")] + public async Task FindManyAsync_HidesOtherTenants() + { + var store = CreateStore("tenant-a"); + await SeedMixedTenantsAsync(store); + + var found = (await store.FindManyAsync(new KeyValueFilter { StartsWith = true, Key = "kv-" }, CancellationToken.None)).ToList(); + + Assert.Equal(2, found.Count); + Assert.Contains(found, x => x.Key == "kv-a"); + Assert.Contains(found, x => x.Key == "kv-star"); + Assert.DoesNotContain(found, x => x.Key == "kv-b"); + } + + [Fact(DisplayName = "FindManyAsync applies Take after tenant admission")] + public async Task FindManyAsync_AppliesTakeAfterTenantFilter() + { + var store = CreateStore("tenant-a"); + await SeedMixedTenantsAsync(store); + await store.SaveAsync(Pair("kv-a2", "a2", "tenant-a"), CancellationToken.None); + + var found = (await store.FindManyAsync(new KeyValueFilter + { + StartsWith = true, + Key = "kv-", + OrderByKey = true, + Take = 3 + }, CancellationToken.None)).ToList(); + + Assert.Equal(["kv-a", "kv-a2", "kv-star"], found.Select(x => x.Key).ToList()); + Assert.DoesNotContain(found, x => x.Key == "kv-b"); + } + + [Fact(DisplayName = "DeleteAsync does not remove another tenant's key")] + public async Task DeleteAsync_DoesNotDeleteOtherTenantKey() + { + var backing = new MemoryStore(); + var tenantA = new MemoryKeyValueStore(backing, new TestTenantAccessor("tenant-a")); + var tenantB = new MemoryKeyValueStore(backing, new TestTenantAccessor("tenant-b")); + await SeedMixedTenantsAsync(tenantA); + + await tenantA.DeleteAsync("kv-b", CancellationToken.None); + var remaining = await tenantB.FindAsync(new KeyValueFilter { Key = "kv-b" }, CancellationToken.None); + + Assert.NotNull(remaining); + Assert.Equal("b", remaining.SerializedValue); + } + + [Fact(DisplayName = "DeleteAsync removes the ambient tenant's own key")] + public async Task DeleteAsync_RemovesOwnKey() + { + var store = CreateStore("tenant-a"); + await SeedMixedTenantsAsync(store); + + await store.DeleteAsync("kv-a", CancellationToken.None); + var remaining = await store.FindAsync(new KeyValueFilter { Key = "kv-a" }, CancellationToken.None); + + Assert.Null(remaining); + } + + [Fact(DisplayName = "DeleteAsync leaves a same-key row after another tenant replaced it")] + public async Task DeleteAsync_WhenSameKeyWasReplacedByOtherTenant_LeavesReplacement() + { + var backing = new MemoryStore(); + var tenantA = new MemoryKeyValueStore(backing, new TestTenantAccessor("tenant-a")); + var tenantB = new MemoryKeyValueStore(backing, new TestTenantAccessor("tenant-b")); + backing.Save(Pair("shared", "a", "tenant-a"), x => x.Id); + backing.Save(Pair("shared", "b", "tenant-b"), x => x.Id); + + await tenantA.DeleteAsync("shared", CancellationToken.None); + var remaining = await tenantB.FindAsync(new KeyValueFilter { Key = "shared" }, CancellationToken.None); + + Assert.NotNull(remaining); + Assert.Equal("tenant-b", remaining.TenantId); + Assert.Equal("b", remaining.SerializedValue); + } + + [Fact(DisplayName = "SaveAsync stamps the ambient tenant when TenantId is unset")] + public async Task SaveAsync_WhenTenantIdUnset_StampsAmbientTenant() + { + var store = CreateStore("tenant-a"); + var pair = Pair("kv-new", "new"); + + await store.SaveAsync(pair, CancellationToken.None); + var found = await store.FindAsync(new KeyValueFilter { Key = "kv-new" }, CancellationToken.None); + + Assert.Equal("tenant-a", pair.TenantId); + Assert.NotNull(found); + Assert.Equal("tenant-a", found.TenantId); + } + + [Fact(DisplayName = "SaveAsync does not overwrite * or an explicit TenantId")] + public async Task SaveAsync_DoesNotOverwriteAgnosticOrExplicitTenantId() + { + var store = CreateStore("tenant-a"); + var agnostic = Pair("kv-star", "star", Tenant.AgnosticTenantId); + var explicitTenant = Pair("kv-a", "a", "tenant-a"); + + await store.SaveAsync(agnostic, CancellationToken.None); + await store.SaveAsync(explicitTenant, CancellationToken.None); + + Assert.Equal(Tenant.AgnosticTenantId, agnostic.TenantId); + Assert.Equal("tenant-a", explicitTenant.TenantId); + } + + [Fact(DisplayName = "FindManyAsync on the default tenant includes null TenantId rows")] + public async Task FindManyAsync_WhenAmbientIsDefault_IncludesNullTenantId() + { + var store = StoreWithPreassignedRows(Tenant.DefaultTenantId); + + var found = (await store.FindManyAsync(new KeyValueFilter { StartsWith = true, Key = "kv-" }, CancellationToken.None)).ToList(); + + Assert.Single(found); + Assert.Equal("kv-null", found[0].Key); + } + + [Fact(DisplayName = "FindManyAsync on a named tenant hides null TenantId rows")] + public async Task FindManyAsync_WhenAmbientIsNamed_HidesNullTenantId() + { + var store = StoreWithPreassignedRows("tenant-a"); + + var found = (await store.FindManyAsync(new KeyValueFilter { StartsWith = true, Key = "kv-" }, CancellationToken.None)).ToList(); + + Assert.Single(found); + Assert.Equal("kv-a", found[0].Key); + } + + private static MemoryKeyValueStore CreateStore(string tenantId) => + new(new MemoryStore(), new TestTenantAccessor(tenantId)); + + private static MemoryKeyValueStore StoreWithPreassignedRows(string ambientTenantId) + { + var backing = new MemoryStore(); + backing.Save(Pair("kv-null", "null"), x => x.Id); + backing.Save(Pair("kv-a", "a", "tenant-a"), x => x.Id); + return new MemoryKeyValueStore(backing, new TestTenantAccessor(ambientTenantId)); + } + + private static async Task SeedMixedTenantsAsync(MemoryKeyValueStore store) + { + await store.SaveAsync(Pair("kv-a", "a", "tenant-a"), CancellationToken.None); + await store.SaveAsync(Pair("kv-b", "b", "tenant-b"), CancellationToken.None); + await store.SaveAsync(Pair("kv-star", "star", Tenant.AgnosticTenantId), CancellationToken.None); + } + + private static SerializedKeyValuePair Pair(string key, string value, string? tenantId = null) => + new() + { + Key = key, + SerializedValue = value, + TenantId = tenantId + }; + + private sealed class TestTenantAccessor(string tenantId) : ITenantAccessor + { + public string TenantId { get; } = tenantId; + public Tenant? Tenant => null; + public IDisposable PushContext(Tenant? tenant) => throw new NotSupportedException(); + } +} From 1c72dc02c837919059b60efe5df2ed57ff2db2d9 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Tue, 15 Sep 2026 21:54:52 +0200 Subject: [PATCH 016/169] chore: bump Elsa.Api.Client to 3.8.2 (#1043) Align Studio's central Elsa.Api.Client pin with nuget.org core 3.8.2 so Packages can republish against that release. Co-authored-by: Cursor Agent --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 70699d3d21..2af701a795 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,7 +4,7 @@ false - + From e7d05ef930dfde2aa85bf9dcaf841e5a6ede0a1e Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Tue, 15 Sep 2026 22:14:27 +0200 Subject: [PATCH 017/169] chore: bump ElsaVersion and ElsaStudioVersion to 3.8.2 (#207) Retarget central Elsa core and Studio package pins so extensions can republish Mongo/Dapper from the release/3.8.2 tip against published 3.8.2 packages. Co-authored-by: Cursor Agent --- Directory.Build.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index a5a687a877..ac499f2e1a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -37,8 +37,8 @@ - 3.8.1 - 3.8.1 + 3.8.2 + 3.8.2 From 9bff3f785fd13bd80a3a7ecf88fec4aec8eef7ae Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sun, 20 Sep 2026 23:23:12 +0200 Subject: [PATCH 018/169] chore(deps): bump Elsa.Api.Client to 3.8.4 (#1052) (#1054) Pin the central Elsa.Api.Client package to the published 3.8.4 stable so the 3.8.4 Studio train does not depend on an unavailable preview the way 3.8.3 did on nuget.org. Co-authored-by: Cursor Agent --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 2af701a795..a0ddaab557 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,7 +4,7 @@ false - + From 154ba15fb4da85b4bebecfbe43639579cbda1d0d Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sun, 20 Sep 2026 23:48:01 +0200 Subject: [PATCH 019/169] chore: bump ElsaVersion and ElsaStudioVersion to 3.8.4 (#215) Retarget central Elsa core and Studio package pins so extensions can publish against nuget.org 3.8.4 after the aligned studio/core cascade. Co-authored-by: Cursor Agent --- Directory.Build.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index ac499f2e1a..90f10fe589 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -37,8 +37,8 @@ - 3.8.2 - 3.8.2 + 3.8.4 + 3.8.4 From 40791856679be276f38cabf224f6ff9302577cb5 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Tue, 22 Sep 2026 09:18:02 +0200 Subject: [PATCH 020/169] chore: set packages base_version to 3.9.0 (#217) Preview package versions on release/3.9.0 should use the 3.9 train. Leave ElsaVersion/ElsaStudioVersion unchanged until core/studio previews exist. Do not cut a stable 3.9.0 tag. Co-authored-by: Cursor Agent --- .github/workflows/packages.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 661b7a2779..b51de385b4 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -15,7 +15,7 @@ on: # published covers stable releases and prereleases; subscribe once. types: [ published ] env: - base_version: '3.8.0' + base_version: '3.9.0' feedz_feed_source: 'https://f.feedz.io/elsa-workflows/elsa-3/nuget/index.json' nuget_feed_source: 'https://api.nuget.org/v3/index.json' # The username of the Trusted Publishing policy creator, not the organization that owns the packages. From f68d5b05a9c88ccd7db160af5503ce751aacb966 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Tue, 22 Sep 2026 09:18:38 +0200 Subject: [PATCH 021/169] chore: packages base_version 3.9.0 + Elsa.Api.Client 3.8.4 (#1056) Point release/3.9.0 preview packages at the 3.9 train and pin Elsa.Api.Client to the published 3.8.4 stable until a matching core preview exists on the feed. Co-authored-by: Cursor Agent --- .github/workflows/packages.yml | 2 +- Directory.Packages.props | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 2c1b2cd41b..710bdbc900 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -19,7 +19,7 @@ on: # on the action) then raced: the second run failed with npm EPUBLISHCONFLICT. types: [ published ] env: - base_version: '3.8.0' + base_version: '3.9.0' nuget_feed_feedzio: 'https://f.feedz.io/elsa-workflows/elsa-3/nuget/index.json' nuget_feed_nuget: 'https://api.nuget.org/v3/index.json' npm_feed_feedzio: 'https://f.feedz.io/elsa-workflows/elsa-3/npm/' diff --git a/Directory.Packages.props b/Directory.Packages.props index adb32141d9..36f1fed756 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,7 +11,7 @@ 0.2.0 - + From 9d0e6fa4ec07818d60dd3e65ccaa84491eac94cf Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Tue, 22 Sep 2026 09:33:26 +0200 Subject: [PATCH 022/169] chore: pin ElsaVersion/ElsaStudioVersion to 3.8.4 (interim) (#218) Unblock compile after #217: replace stale 3.8.0-preview.5557 / 3.8.0 with published 3.8.4. packages.yml base_version stays 3.9.0. Co-authored-by: Cursor Agent --- Directory.Build.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index f84eccde5f..90f10fe589 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -37,8 +37,8 @@ - 3.8.0-preview.5557 - 3.8.0 + 3.8.4 + 3.8.4 From fc72be2e18ca77b9441982b4d9767eafd52f30fe Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Fri, 25 Sep 2026 00:12:22 +0200 Subject: [PATCH 023/169] ci: run pr workflow for release/* branches (#1057) PRs targeting release/* currently skip the PR build/test workflow because it only listed main. Add the release/* trigger so those PRs get the same CI. Co-authored-by: Cursor Agent --- .github/workflows/pr.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index a7059fc7be..9661694232 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -4,6 +4,7 @@ on: pull_request: branches: - 'main' + - 'release/*' permissions: contents: read From 2b210a5872cabababb30578b8c8e1ad07d40af67 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Fri, 25 Sep 2026 00:13:50 +0200 Subject: [PATCH 024/169] ci: run pr workflow for release/* branches (#219) PRs targeting release/* previously skipped the generated pr workflow because OnPullRequestBranches listed only main. Add release/* and regenerate GitHubActions_pr so build/test CI runs on release trains. Co-authored-by: Cursor Agent --- .github/workflows/pr.yml | 1 + build/Build.CI.GitHubActions.cs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 743b29cd2c..ce378bb87e 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -20,6 +20,7 @@ on: pull_request: branches: - main + - 'release/*' paths: - '**/*' diff --git a/build/Build.CI.GitHubActions.cs b/build/Build.CI.GitHubActions.cs index 7030d47173..e88af9df5c 100644 --- a/build/Build.CI.GitHubActions.cs +++ b/build/Build.CI.GitHubActions.cs @@ -8,7 +8,7 @@ [CustomGitHubActions( "pr", GitHubActionsImage.UbuntuLatest, - OnPullRequestBranches = ["main"], + OnPullRequestBranches = ["main", "release/*"], OnPullRequestIncludePaths = ["**/*"], PublishArtifacts = false, InvokedTargets = [nameof(ICompile.Compile), nameof(ITest.Test), nameof(IPack.Pack)], From 338c90afcd9f128a6ad624080dd43794a4295619 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 24 Sep 2026 22:34:14 +0000 Subject: [PATCH 025/169] chore: pin ElsaVersion to 3.9.0-preview.5708 Compile release/3.9.0 against core 3.9.0-preview.5708 from feedz instead of 3.8.4. Implement IWorkflowDefinitionStore.TryUpdateLatestAsync on the Mongo and Dapper stores to match the core 3.9.0 API. ElsaStudioVersion stays 3.8.4. Fixes #220 Co-authored-by: Sipke Schoorstra --- Directory.Build.props | 2 +- .../Stores/DapperWorkflowDefinitionStore.cs | 29 +++++++++++++++++++ .../Management/WorkflowDefinitionStore.cs | 27 +++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 90f10fe589..fdb2ac0813 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -37,7 +37,7 @@ - 3.8.4 + 3.9.0-preview.5708 3.8.4 diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowDefinitionStore.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowDefinitionStore.cs index c2b1210f8a..5272000784 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowDefinitionStore.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowDefinitionStore.cs @@ -120,6 +120,35 @@ public async Task SaveManyAsync(IEnumerable definitions, Can await store.SaveManyAsync(records, cancellationToken); } + /// + public async Task TryUpdateLatestAsync( + WorkflowDefinitionFilter filter, + Func matchesExpected, + Func update, + CancellationToken cancellationToken = default) + { + var record = await store.FindAsync(q => ApplyFilter(q, filter), cancellationToken); + + if (record == null) + return WorkflowDefinitionUpdateResult.NotFound(); + + var current = Map(record); + + if (!current.IsLatest || !matchesExpected(current)) + return WorkflowDefinitionUpdateResult.Conflict(); + + var next = update(current); + + if (next.Id != current.Id) + { + current.IsLatest = false; + await store.SaveAsync(Map(current), cancellationToken); + } + + await store.SaveAsync(Map(next), cancellationToken); + return WorkflowDefinitionUpdateResult.Updated(next); + } + /// public async Task DeleteAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default) { diff --git a/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowDefinitionStore.cs b/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowDefinitionStore.cs index dc6f521479..36f5b5dfb7 100644 --- a/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowDefinitionStore.cs +++ b/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowDefinitionStore.cs @@ -127,6 +127,33 @@ public Task SaveManyAsync(IEnumerable definitions, Cancellat return mongoDbStore.SaveManyAsync(definitions.Select(i => i), cancellationToken); } + /// + public async Task TryUpdateLatestAsync( + WorkflowDefinitionFilter filter, + Func matchesExpected, + Func update, + CancellationToken cancellationToken = default) + { + var current = await mongoDbStore.FindAsync(queryable => Filter(queryable, filter), filter.TenantAgnostic, cancellationToken); + + if (current == null) + return WorkflowDefinitionUpdateResult.NotFound(); + + if (!current.IsLatest || !matchesExpected(current)) + return WorkflowDefinitionUpdateResult.Conflict(); + + var next = update(current); + + if (next.Id != current.Id) + { + current.IsLatest = false; + await mongoDbStore.SaveAsync(current, cancellationToken); + } + + await mongoDbStore.SaveAsync(next, cancellationToken); + return WorkflowDefinitionUpdateResult.Updated(next); + } + /// public async Task DeleteAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default) { From 583f75825e63a713b00b499a2e9d27fc2939fb0f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 24 Sep 2026 22:50:51 +0000 Subject: [PATCH 026/169] fix: backport TryUpdateLatestAsync from main (#216) Replace the weaker release-branch compare-and-swap with main's Mongo conditional writes and Dapper TenantId/DefinitionId/ToolVersion guards. Add Dapper SQLite and Mongo Testcontainers tests modeled on core's MemoryWorkflowDefinitionStoreCompareAndSwapTests. Co-authored-by: Sipke Schoorstra --- .../Stores/DapperWorkflowDefinitionStore.cs | 31 ++- .../Management/WorkflowDefinitionStore.cs | 58 +++- ...kflowDefinitionStoreCompareAndSwapTests.cs | 259 +++++++++++++++++ ...kflowDefinitionStoreCompareAndSwapTests.cs | 260 ++++++++++++++++++ 4 files changed, 584 insertions(+), 24 deletions(-) create mode 100644 test/modules/persistence/Elsa.MongoDb.UnitTests/MongoWorkflowDefinitionStoreCompareAndSwapTests.cs create mode 100644 test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperWorkflowDefinitionStoreCompareAndSwapTests.cs diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowDefinitionStore.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowDefinitionStore.cs index 5272000784..e46fd01076 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowDefinitionStore.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowDefinitionStore.cs @@ -113,13 +113,6 @@ public async Task SaveAsync(WorkflowDefinition definition, CancellationToken can await store.SaveAsync(record, cancellationToken); } - /// - public async Task SaveManyAsync(IEnumerable definitions, CancellationToken cancellationToken = default) - { - var records = definitions.Select(Map).ToList(); - await store.SaveManyAsync(records, cancellationToken); - } - /// public async Task TryUpdateLatestAsync( WorkflowDefinitionFilter filter, @@ -129,7 +122,7 @@ public async Task TryUpdateLatestAsync( { var record = await store.FindAsync(q => ApplyFilter(q, filter), cancellationToken); - if (record == null) + if (record is null) return WorkflowDefinitionUpdateResult.NotFound(); var current = Map(record); @@ -139,16 +132,30 @@ public async Task TryUpdateLatestAsync( var next = update(current); - if (next.Id != current.Id) + if (next.TenantId != record.TenantId || next.DefinitionId != record.DefinitionId) + throw new InvalidOperationException("An atomic workflow update cannot change its tenant or logical definition."); + + var nextRecord = Map(next); + // ToolVersion is stored on the record but not on the public entity; keep the loaded column. + nextRecord.ToolVersion = record.ToolVersion; + + if (next.Id != record.Id) { - current.IsLatest = false; - await store.SaveAsync(Map(current), cancellationToken); + record.IsLatest = false; + await store.SaveAsync(record, cancellationToken); } - await store.SaveAsync(Map(next), cancellationToken); + await store.SaveAsync(nextRecord, cancellationToken); return WorkflowDefinitionUpdateResult.Updated(next); } + /// + public async Task SaveManyAsync(IEnumerable definitions, CancellationToken cancellationToken = default) + { + var records = definitions.Select(Map).ToList(); + await store.SaveManyAsync(records, cancellationToken); + } + /// public async Task DeleteAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default) { diff --git a/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowDefinitionStore.cs b/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowDefinitionStore.cs index 36f5b5dfb7..269fa0b643 100644 --- a/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowDefinitionStore.cs +++ b/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowDefinitionStore.cs @@ -121,12 +121,6 @@ public Task SaveAsync(WorkflowDefinition definition, CancellationToken cancellat return mongoDbStore.SaveAsync(definition, cancellationToken); } - /// - public Task SaveManyAsync(IEnumerable definitions, CancellationToken cancellationToken = default) - { - return mongoDbStore.SaveManyAsync(definitions.Select(i => i), cancellationToken); - } - /// public async Task TryUpdateLatestAsync( WorkflowDefinitionFilter filter, @@ -134,26 +128,66 @@ public async Task TryUpdateLatestAsync( Func update, CancellationToken cancellationToken = default) { - var current = await mongoDbStore.FindAsync(queryable => Filter(queryable, filter), filter.TenantAgnostic, cancellationToken); + var current = await FindAsync(filter, cancellationToken); - if (current == null) + if (current is null) return WorkflowDefinitionUpdateResult.NotFound(); if (!current.IsLatest || !matchesExpected(current)) return WorkflowDefinitionUpdateResult.Conflict(); + var expectedId = current.Id; + var expectedVersion = current.Version; + var expectedStringData = current.StringData; + var expectedName = current.Name; + var expectedDescription = current.Description; + var expectedTenantId = current.TenantId; + var expectedDefinitionId = current.DefinitionId; var next = update(current); - if (next.Id != current.Id) + if (!string.Equals(next.DefinitionId, expectedDefinitionId, StringComparison.Ordinal)) + throw new InvalidOperationException("An atomic workflow update cannot change its logical definition."); + + next.TenantId = expectedTenantId; + + var snapshotFilter = Builders.Filter.And( + Builders.Filter.Eq(x => x.Id, expectedId), + Builders.Filter.Eq(x => x.Version, expectedVersion), + Builders.Filter.Eq(x => x.IsLatest, true), + Builders.Filter.Eq(x => x.StringData, expectedStringData), + Builders.Filter.Eq(x => x.Name, expectedName), + Builders.Filter.Eq(x => x.Description, expectedDescription), + Builders.Filter.Eq(x => x.TenantId, expectedTenantId)); + + var collection = mongoDbStore.GetCollection(); + + if (next.Id == expectedId) { - current.IsLatest = false; - await mongoDbStore.SaveAsync(current, cancellationToken); + var replaceResult = await collection.ReplaceOneAsync(snapshotFilter, next, cancellationToken: cancellationToken); + return replaceResult.MatchedCount == 1 + ? WorkflowDefinitionUpdateResult.Updated(next) + : WorkflowDefinitionUpdateResult.Conflict(); } - await mongoDbStore.SaveAsync(next, cancellationToken); + var unmarked = await collection.UpdateOneAsync( + snapshotFilter, + Builders.Update.Set(x => x.IsLatest, false), + cancellationToken: cancellationToken); + + if (unmarked.MatchedCount != 1) + return WorkflowDefinitionUpdateResult.Conflict(); + + next.IsLatest = true; + await collection.InsertOneAsync(next, cancellationToken: cancellationToken); return WorkflowDefinitionUpdateResult.Updated(next); } + /// + public Task SaveManyAsync(IEnumerable definitions, CancellationToken cancellationToken = default) + { + return mongoDbStore.SaveManyAsync(definitions.Select(i => i), cancellationToken); + } + /// public async Task DeleteAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default) { diff --git a/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoWorkflowDefinitionStoreCompareAndSwapTests.cs b/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoWorkflowDefinitionStoreCompareAndSwapTests.cs new file mode 100644 index 0000000000..00e89116a6 --- /dev/null +++ b/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoWorkflowDefinitionStoreCompareAndSwapTests.cs @@ -0,0 +1,259 @@ +using Elsa.Common.Models; +using Elsa.Common.Multitenancy; +using Elsa.Persistence.MongoDb.Common; +using Elsa.Persistence.MongoDb.Modules.Management; +using Elsa.Workflows.Management.Entities; +using Elsa.Workflows.Management.Filters; +using Elsa.Workflows.Management.Models; +using MongoDB.Driver; +using Testcontainers.MongoDb; + +namespace Elsa.MongoDb.UnitTests; + +/// +/// is the compare-and-swap the BPMN document +/// PUT uses: load, match, apply, a snapshot-filtered write. Lost match is Conflict, not an overwrite. +/// +public sealed class MongoWorkflowDefinitionStoreCompareAndSwapTests : IAsyncLifetime +{ + private readonly MongoDbContainer _container = new MongoDbBuilder().WithImage("mongo:7.0.24").Build(); + private readonly TestTenantAccessor _tenantAccessor = new(); + private MongoClient? _client; + private IMongoCollection _collection = null!; + private MongoWorkflowDefinitionStore _store = null!; + + public async Task InitializeAsync() + { + try + { + await _container.StartAsync(); + + _client = new MongoClient(_container.GetConnectionString()); + var database = _client.GetDatabase($"elsa-definitions-{Guid.NewGuid():N}"); + _collection = database.GetCollection("workflow_definitions"); + var mongoDbStore = new MongoDbStore(_collection, _tenantAccessor); + _store = new MongoWorkflowDefinitionStore(mongoDbStore); + } + catch + { + await DisposeAsync(); + throw; + } + } + + public async Task DisposeAsync() + { + try + { + _client?.Dispose(); + } + finally + { + await _container.DisposeAsync(); + } + } + + [Fact(DisplayName = "A missing definition is NotFound")] + public async Task TryUpdateLatestAsync_WhenNothingMatchesTheFilter_ReturnsNotFound() + { + using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + + var result = await _store.TryUpdateLatestAsync( + LatestOf("missing"), + _ => true, + current => current); + + Assert.Equal(WorkflowDefinitionUpdateOutcome.NotFound, result.Outcome); + Assert.Null(result.Definition); + } + + [Fact(DisplayName = "A match that fails is Conflict and the stored row is unchanged")] + public async Task TryUpdateLatestAsync_WhenTheRowDoesNotMatch_ReturnsConflictAndWritesNothing() + { + using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + await _store.SaveAsync(Definition("def-1", "id-1", name: "Original", stringData: "graph-v2")); + + var result = await _store.TryUpdateLatestAsync( + LatestOf("def-1"), + loaded => loaded.StringData == "graph-v1", + loaded => + { + var next = loaded.ShallowClone(); + next.StringData = "should-not-be-saved"; + return next; + }); + + Assert.Equal(WorkflowDefinitionUpdateOutcome.Conflict, result.Outcome); + Assert.Null(result.Definition); + + var stored = await _store.FindAsync(LatestOf("def-1")); + Assert.Equal("graph-v2", stored!.StringData); + Assert.Equal("Original", stored.Name); + } + + [Fact(DisplayName = "A loaded row that is no longer IsLatest is Conflict and writes nothing")] + public async Task TryUpdateLatestAsync_WhenTheLoadedRowIsNoLongerLatest_ReturnsConflictAndWritesNothing() + { + using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + var published = Definition("def-1", "id-1", name: "Published", stringData: "graph-v1"); + published.IsPublished = true; + await _store.SaveAsync(published); + + var winner = await _store.TryUpdateLatestAsync( + LatestOf("def-1"), + _ => true, + loaded => + { + var draft = loaded.ShallowClone(); + draft.Id = "id-2"; + draft.Version = loaded.Version + 1; + draft.IsPublished = false; + draft.StringData = "winner-draft"; + return draft; + }); + + Assert.Equal(WorkflowDefinitionUpdateOutcome.Updated, winner.Outcome); + + var loser = await _store.TryUpdateLatestAsync( + new WorkflowDefinitionFilter { Id = published.Id }, + _ => true, + loaded => + { + var draft = loaded.ShallowClone(); + draft.Id = "id-3"; + draft.Version = loaded.Version + 1; + draft.IsPublished = false; + draft.StringData = "should-not-be-saved"; + return draft; + }); + + Assert.Equal(WorkflowDefinitionUpdateOutcome.Conflict, loser.Outcome); + Assert.Null(loser.Definition); + + var stored = await _store.FindAsync(LatestOf("def-1")); + Assert.Equal("id-2", stored!.Id); + Assert.Equal("winner-draft", stored.StringData); + } + + [Fact(DisplayName = "A matching latest row is updated and the callback sees that just-loaded row")] + public async Task TryUpdateLatestAsync_WhenTheRowMatches_SavesTheUpdateBuiltFromTheLoadedRow() + { + using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + await _store.SaveAsync(Definition("def-1", "id-1", name: "Original", stringData: "graph-v1")); + + var result = await _store.TryUpdateLatestAsync( + LatestOf("def-1"), + loaded => loaded.StringData == "graph-v1", + loaded => + { + var next = loaded.ShallowClone(); + next.StringData = "graph-v2"; + next.Name = loaded.Name + "-kept"; + return next; + }); + + Assert.Equal(WorkflowDefinitionUpdateOutcome.Updated, result.Outcome); + Assert.Equal("graph-v2", result.Definition!.StringData); + Assert.Equal("Original-kept", result.Definition.Name); + + var stored = await _store.FindAsync(LatestOf("def-1")); + Assert.Equal("graph-v2", stored!.StringData); + Assert.Equal("Original-kept", stored.Name); + Assert.Equal("id-1", stored.Id); + } + + [Fact(DisplayName = "A new draft from a published version unmarks the old latest")] + public async Task TryUpdateLatestAsync_WhenANewDraftIsCreated_UnmarksThePreviousLatest() + { + using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + var published = Definition("def-1", "id-1", name: "Published", stringData: "graph-v1"); + published.IsPublished = true; + await _store.SaveAsync(published); + + var result = await _store.TryUpdateLatestAsync( + LatestOf("def-1"), + _ => true, + loaded => + { + var draft = loaded.ShallowClone(); + draft.Id = "id-2"; + draft.Version = loaded.Version + 1; + draft.IsPublished = false; + draft.StringData = "draft-graph"; + return draft; + }); + + Assert.Equal(WorkflowDefinitionUpdateOutcome.Updated, result.Outcome); + Assert.Equal("id-2", result.Definition!.Id); + + var latest = await _store.FindAsync(LatestOf("def-1")); + Assert.Equal("id-2", latest!.Id); + Assert.True(latest.IsLatest); + + var versions = (await _store.FindManyAsync(new WorkflowDefinitionFilter { DefinitionId = "def-1" })).ToList(); + Assert.Equal(2, versions.Count); + Assert.Equal(1, versions.Count(x => x.IsLatest)); + Assert.False(versions.Single(x => x.Id == "id-1").IsLatest); + } + + [Fact(DisplayName = "A row that changed after load is Conflict and the stored graph stays")] + public async Task TryUpdateLatestAsync_WhenTheRowChangedAfterLoad_ReturnsConflictAndWritesNothing() + { + using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + await _store.SaveAsync(Definition("def-1", "id-1", name: "Original", stringData: "graph-v1")); + + var result = await _store.TryUpdateLatestAsync( + LatestOf("def-1"), + loaded => loaded.StringData == "graph-v1", + loaded => + { + _collection.UpdateOne( + Builders.Filter.Eq(x => x.Id, loaded.Id), + Builders.Update.Set(x => x.StringData, "concurrent-write")); + + var next = loaded.ShallowClone(); + next.StringData = "stale-overwrite"; + return next; + }); + + Assert.Equal(WorkflowDefinitionUpdateOutcome.Conflict, result.Outcome); + Assert.Null(result.Definition); + + var stored = await _store.FindAsync(LatestOf("def-1")); + Assert.Equal("concurrent-write", stored!.StringData); + Assert.Equal("Original", stored.Name); + } + + private static WorkflowDefinitionFilter LatestOf(string definitionId) => + new() { DefinitionId = definitionId, VersionOptions = VersionOptions.Latest }; + + private static WorkflowDefinition Definition(string definitionId, string id, string name, string stringData) => + new() + { + Id = id, + DefinitionId = definitionId, + Name = name, + StringData = stringData, + Version = 1, + IsLatest = true, + MaterializerName = "Json" + }; + + private sealed class TestTenantAccessor : ITenantAccessor + { + public string TenantId => Tenant?.Id ?? Tenant.DefaultTenantId; + public Tenant? Tenant { get; private set; } + + public IDisposable PushContext(Tenant? tenant) + { + var previousTenant = Tenant; + Tenant = tenant; + return new Restore(() => Tenant = previousTenant); + } + + private sealed class Restore(Action restore) : IDisposable + { + public void Dispose() => restore(); + } + } +} diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperWorkflowDefinitionStoreCompareAndSwapTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperWorkflowDefinitionStoreCompareAndSwapTests.cs new file mode 100644 index 0000000000..fb560e0ec6 --- /dev/null +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperWorkflowDefinitionStoreCompareAndSwapTests.cs @@ -0,0 +1,260 @@ +using System.Text.Json; +using Dapper; +using Elsa.Common.Models; +using Elsa.Common.Multitenancy; +using Elsa.Persistence.Dapper.Modules.Management.Records; +using Elsa.Persistence.Dapper.Modules.Management.Stores; +using Elsa.Persistence.Dapper.Services; +using Elsa.Workflows; +using Elsa.Workflows.Management.Entities; +using Elsa.Workflows.Management.Filters; +using Elsa.Workflows.Management.Models; +using Microsoft.Data.Sqlite; + +namespace Elsa.Persistence.Dapper.UnitTests; + +/// +/// is the compare-and-swap the BPMN document +/// PUT uses: load, match, apply, save. Lost match is Conflict, not an overwrite. +/// +public sealed class DapperWorkflowDefinitionStoreCompareAndSwapTests : IDisposable +{ + private readonly string _databasePath = Path.Combine(Path.GetTempPath(), $"elsa-dapper-definitions-{Guid.NewGuid():N}.db"); + private readonly DapperWorkflowDefinitionStore _store; + private readonly TestTenantAccessor _tenantAccessor = new(); + + public DapperWorkflowDefinitionStoreCompareAndSwapTests() + { + var connectionString = new SqliteConnectionStringBuilder { DataSource = _databasePath, Pooling = false }.ToString(); + var connectionProvider = new SqliteDbConnectionProvider(connectionString); + + using var connection = new SqliteConnection(connectionString); + connection.Open(); + connection.Execute(""" + create table WorkflowDefinitions ( + Id text not null primary key, + DefinitionId text not null, + Name text null, + ToolVersion text null, + Description text null, + ProviderName text null, + MaterializerName text not null, + MaterializerContext text null, + Props text not null, + UsableAsActivity integer null, + StringData text null, + BinaryData blob null, + CreatedAt text not null, + Version integer not null, + IsLatest integer not null, + IsReadonly integer not null, + IsPublished integer not null, + IsSystem integer null, + TenantId text null + ); + """); + + var store = new Store(connectionProvider, _tenantAccessor, "WorkflowDefinitions"); + _store = new DapperWorkflowDefinitionStore(store, new JsonPayloadSerializer()); + } + + [Fact(DisplayName = "A missing definition is NotFound")] + public async Task TryUpdateLatestAsync_WhenNothingMatchesTheFilter_ReturnsNotFound() + { + using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + + var result = await _store.TryUpdateLatestAsync( + LatestOf("missing"), + _ => true, + current => current); + + Assert.Equal(WorkflowDefinitionUpdateOutcome.NotFound, result.Outcome); + Assert.Null(result.Definition); + } + + [Fact(DisplayName = "A match that fails is Conflict and the stored row is unchanged")] + public async Task TryUpdateLatestAsync_WhenTheRowDoesNotMatch_ReturnsConflictAndWritesNothing() + { + using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + await _store.SaveAsync(Definition("def-1", "id-1", name: "Original", stringData: "graph-v2")); + + var result = await _store.TryUpdateLatestAsync( + LatestOf("def-1"), + loaded => loaded.StringData == "graph-v1", + loaded => + { + var next = loaded.ShallowClone(); + next.StringData = "should-not-be-saved"; + return next; + }); + + Assert.Equal(WorkflowDefinitionUpdateOutcome.Conflict, result.Outcome); + Assert.Null(result.Definition); + + var stored = await _store.FindAsync(LatestOf("def-1")); + Assert.Equal("graph-v2", stored!.StringData); + Assert.Equal("Original", stored.Name); + } + + [Fact(DisplayName = "A loaded row that is no longer IsLatest is Conflict and writes nothing")] + public async Task TryUpdateLatestAsync_WhenTheLoadedRowIsNoLongerLatest_ReturnsConflictAndWritesNothing() + { + using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + var published = Definition("def-1", "id-1", name: "Published", stringData: "graph-v1"); + published.IsPublished = true; + await _store.SaveAsync(published); + + var winner = await _store.TryUpdateLatestAsync( + LatestOf("def-1"), + _ => true, + loaded => + { + var draft = loaded.ShallowClone(); + draft.Id = "id-2"; + draft.Version = loaded.Version + 1; + draft.IsPublished = false; + draft.StringData = "winner-draft"; + return draft; + }); + + Assert.Equal(WorkflowDefinitionUpdateOutcome.Updated, winner.Outcome); + + var loser = await _store.TryUpdateLatestAsync( + new WorkflowDefinitionFilter { Id = published.Id }, + _ => true, + loaded => + { + var draft = loaded.ShallowClone(); + draft.Id = "id-3"; + draft.Version = loaded.Version + 1; + draft.IsPublished = false; + draft.StringData = "should-not-be-saved"; + return draft; + }); + + Assert.Equal(WorkflowDefinitionUpdateOutcome.Conflict, loser.Outcome); + Assert.Null(loser.Definition); + + var stored = await _store.FindAsync(LatestOf("def-1")); + Assert.Equal("id-2", stored!.Id); + Assert.Equal("winner-draft", stored.StringData); + } + + [Fact(DisplayName = "A matching latest row is updated and the callback sees that just-loaded row")] + public async Task TryUpdateLatestAsync_WhenTheRowMatches_SavesTheUpdateBuiltFromTheLoadedRow() + { + using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + await _store.SaveAsync(Definition("def-1", "id-1", name: "Original", stringData: "graph-v1")); + + var result = await _store.TryUpdateLatestAsync( + LatestOf("def-1"), + loaded => loaded.StringData == "graph-v1", + loaded => + { + var next = loaded.ShallowClone(); + next.StringData = "graph-v2"; + next.Name = loaded.Name + "-kept"; + return next; + }); + + Assert.Equal(WorkflowDefinitionUpdateOutcome.Updated, result.Outcome); + Assert.Equal("graph-v2", result.Definition!.StringData); + Assert.Equal("Original-kept", result.Definition.Name); + + var stored = await _store.FindAsync(LatestOf("def-1")); + Assert.Equal("graph-v2", stored!.StringData); + Assert.Equal("Original-kept", stored.Name); + Assert.Equal("id-1", stored.Id); + } + + [Fact(DisplayName = "A new draft from a published version unmarks the old latest")] + public async Task TryUpdateLatestAsync_WhenANewDraftIsCreated_UnmarksThePreviousLatest() + { + using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + var published = Definition("def-1", "id-1", name: "Published", stringData: "graph-v1"); + published.IsPublished = true; + await _store.SaveAsync(published); + + var result = await _store.TryUpdateLatestAsync( + LatestOf("def-1"), + _ => true, + loaded => + { + var draft = loaded.ShallowClone(); + draft.Id = "id-2"; + draft.Version = loaded.Version + 1; + draft.IsPublished = false; + draft.StringData = "draft-graph"; + return draft; + }); + + Assert.Equal(WorkflowDefinitionUpdateOutcome.Updated, result.Outcome); + Assert.Equal("id-2", result.Definition!.Id); + + var latest = await _store.FindAsync(LatestOf("def-1")); + Assert.Equal("id-2", latest!.Id); + Assert.True(latest.IsLatest); + + var versions = (await _store.FindManyAsync(new WorkflowDefinitionFilter { DefinitionId = "def-1" })).ToList(); + Assert.Equal(2, versions.Count); + Assert.Equal(1, versions.Count(x => x.IsLatest)); + Assert.False(versions.Single(x => x.Id == "id-1").IsLatest); + } + + public void Dispose() + { + File.Delete(_databasePath); + } + + private static WorkflowDefinitionFilter LatestOf(string definitionId) => + new() { DefinitionId = definitionId, VersionOptions = VersionOptions.Latest }; + + private static WorkflowDefinition Definition(string definitionId, string id, string name, string stringData) => + new() + { + Id = id, + DefinitionId = definitionId, + Name = name, + StringData = stringData, + Version = 1, + IsLatest = true, + MaterializerName = "Json" + }; + + private sealed class JsonPayloadSerializer : IPayloadSerializer + { + public string Serialize(object payload) => JsonSerializer.Serialize(payload); + + public JsonElement SerializeToElement(object payload) => JsonSerializer.SerializeToElement(payload); + + public object Deserialize(string serializedData) => JsonSerializer.Deserialize(serializedData)!; + + public object Deserialize(string serializedData, Type type) => JsonSerializer.Deserialize(serializedData, type)!; + + public object Deserialize(JsonElement serializedData) => serializedData.Deserialize()!; + + public T Deserialize(string serializedData) => JsonSerializer.Deserialize(serializedData)!; + + public T Deserialize(JsonElement serializedData) => serializedData.Deserialize()!; + + public JsonSerializerOptions GetOptions() => new(); + } + + private sealed class TestTenantAccessor : ITenantAccessor + { + public string TenantId => Tenant?.Id ?? Tenant.DefaultTenantId; + public Tenant? Tenant { get; private set; } + + public IDisposable PushContext(Tenant? tenant) + { + var previousTenant = Tenant; + Tenant = tenant; + return new Restore(() => Tenant = previousTenant); + } + + private sealed class Restore(Action restore) : IDisposable + { + public void Dispose() => restore(); + } + } +} From 90a22b4d079bb1c3c3b9801117839756be01d0b1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 25 Sep 2026 07:17:41 +0000 Subject: [PATCH 027/169] test: register ITimeFormatter in ActivityExecutionDetailsTests 3.9 DataPanel injects ITimeFormatter; the 3.8.4 drawer tests did not register it and failed after the forward-merge. Co-authored-by: Sipke Schoorstra --- .../ActivityExecutionDetailsTests.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/modules/Elsa.Studio.Workflows.Tests/ActivityExecutionDetailsTests.cs b/src/modules/Elsa.Studio.Workflows.Tests/ActivityExecutionDetailsTests.cs index 295ea0566f..df88da4a36 100644 --- a/src/modules/Elsa.Studio.Workflows.Tests/ActivityExecutionDetailsTests.cs +++ b/src/modules/Elsa.Studio.Workflows.Tests/ActivityExecutionDetailsTests.cs @@ -4,6 +4,7 @@ using Elsa.Api.Client.Shared.Models; using Elsa.Studio.DomInterop.Contracts; using Elsa.Studio.Localization; +using Elsa.Studio.Localization.Time; using Elsa.Studio.Workflows.Components.WorkflowInstanceViewer.Components; using Elsa.Studio.Workflows.Domain.Contracts; using Microsoft.Extensions.DependencyInjection; @@ -23,6 +24,7 @@ public ActivityExecutionDetailsTests() JSInterop.Mode = JSRuntimeMode.Loose; Services.AddMudServices(); Services.AddSingleton(); + Services.AddSingleton(); Services.AddSingleton(_executionService); Services.AddSingleton(); Render(); @@ -140,6 +142,12 @@ private sealed class ClipboardStub : IClipboard public Task CopyText(string text, CancellationToken cancellationToken = default) => Task.CompletedTask; } + private sealed class TestTimeFormatter : ITimeFormatter + { + public string Format(DateTimeOffset? value, string format = "G", string emptyString = "") => + value?.ToString(format) ?? emptyString; + } + private sealed class TestLocalizer : ILocalizer { public LocalizedString this[string? key] => new(key ?? string.Empty, key ?? string.Empty); From 05bc3c881cc15e0c9c7f6ce882f8c3e1653de751 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 25 Sep 2026 10:24:49 +0000 Subject: [PATCH 028/169] fix(environments): defer environments load off Blazor Server host startup Stop registering LoadEnvironmentsStartupTask as IStartupTask. On Blazor Server that task ran from RunStartupTasksHostedService during Host.StartAsync, which hit ElsaIdentity JWT/localStorage JS interop and crashed the host (#1062). Load environments once from circuit-scoped Feature.InitializeAsync via EnvironmentLoader after the app shell has a user context. Failures are logged and leave the picker empty. WASM still populates the picker from the same feature-init path. Co-authored-by: Sipke Schoorstra --- .../EnvironmentsModuleTests.cs | 83 +++++++++++++++++-- .../Extensions/ServiceCollectionExtensions.cs | 8 +- .../Elsa.Studio.Environments/Module.cs | 26 +----- .../Services/EnvironmentLoader.cs | 57 +++++++++++++ .../Tasks/LoadEnvironmentsStartupTask.cs | 46 ---------- 5 files changed, 144 insertions(+), 76 deletions(-) create mode 100644 src/modules/Elsa.Studio.Environments/Services/EnvironmentLoader.cs delete mode 100644 src/modules/Elsa.Studio.Environments/Tasks/LoadEnvironmentsStartupTask.cs diff --git a/src/modules/Elsa.Studio.Environments.Tests/EnvironmentsModuleTests.cs b/src/modules/Elsa.Studio.Environments.Tests/EnvironmentsModuleTests.cs index 9ded9cc4f7..642fe73ace 100644 --- a/src/modules/Elsa.Studio.Environments.Tests/EnvironmentsModuleTests.cs +++ b/src/modules/Elsa.Studio.Environments.Tests/EnvironmentsModuleTests.cs @@ -6,7 +6,6 @@ using Elsa.Studio.Environments.Extensions; using Elsa.Studio.Environments.Models; using Elsa.Studio.Environments.Services; -using Elsa.Studio.Environments.Tasks; using Elsa.Studio.Extensions; using Elsa.Studio.Models; using Elsa.Studio.Services; @@ -20,6 +19,10 @@ namespace Elsa.Studio.Environments.Tests; /// updating and reusing , /// not by replacing it with a private ServiceProvider factory. /// +/// +/// Also covers #1062: Blazor Server host startup must not call the environments client. +/// The list loads once from the deferred Feature / path. +/// public class EnvironmentsModuleTests : IDisposable { private readonly RecordingHandler _primaryHandler = new(); @@ -59,7 +62,30 @@ public void AddEnvironmentsModule_KeepsDefaultBackendApiClientProvider() } [Fact] - public async Task LoadEnvironmentsStartupTask_PopulatesEnvironmentService() + public void AddEnvironmentsModule_DoesNotRegisterStartupTask() + { + Assert.Empty(_serviceProvider.GetServices()); + Assert.Null(typeof(EnvironmentLoader).Assembly.GetType( + "Elsa.Studio.Environments.Tasks.LoadEnvironmentsStartupTask")); + } + + [Fact] + public async Task BlazorServerStartupPath_DoesNotCallEnvironmentsClient() + { + // Arrange: same path as RunStartupTasksHostedService during Host.StartAsync. + var runner = _serviceProvider.GetRequiredService(); + + // Act + await runner.RunStartupTasksAsync(); + + // Assert + Assert.Null(_primaryHandler.LastRequest); + Assert.Equal(0, _primaryHandler.RequestCount); + Assert.Empty(_serviceProvider.GetRequiredService().Environments); + } + + [Fact] + public async Task FeatureInitialize_LoadsEnvironmentsOnce() { _primaryHandler.ResponseContent = """ { @@ -71,15 +97,41 @@ public async Task LoadEnvironmentsStartupTask_PopulatesEnvironmentService() } """; - var task = _serviceProvider.GetServices().OfType().Single(); - await task.LoadAsync(); - + var feature = _serviceProvider.GetServices().OfType().Single(); var environments = _serviceProvider.GetRequiredService(); + + await feature.InitializeAsync(); + await feature.InitializeAsync(); + + Assert.Equal(1, _primaryHandler.RequestCount); Assert.Equal(["Dev", "Prod"], environments.Environments.Select(environment => environment.Name)); Assert.Equal("Dev", environments.CurrentEnvironment?.Name); Assert.Equal(new Uri("https://dev.example/"), environments.CurrentEnvironment?.Url); } + [Fact] + public async Task EnvironmentLoader_EnsureLoadedAsync_LoadsOnce() + { + _primaryHandler.ResponseContent = """ + { + "environments": [ + { "name": "Staging", "url": "https://staging.example/" } + ], + "defaultEnvironmentName": "Staging" + } + """; + + var loader = _serviceProvider.GetRequiredService(); + + await loader.EnsureLoadedAsync(); + await loader.EnsureLoadedAsync(); + + var environments = _serviceProvider.GetRequiredService(); + Assert.Equal(1, _primaryHandler.RequestCount); + Assert.Equal("Staging", environments.CurrentEnvironment?.Name); + Assert.Contains(environments.Environments, environment => environment.Name == "Staging"); + } + [Fact] public async Task FeatureInitialize_FillsEnvironmentsForThePicker() { @@ -113,6 +165,20 @@ public async Task FeatureInitialize_DoesNotThrowWhenEnvironmentsApiIsMissing() Assert.Contains(_serviceProvider.GetRequiredService().AppBarElements, _ => true); } + [Fact] + public async Task FeatureInitialize_DoesNotThrowWhenJavaScriptInteropIsUnavailable() + { + _primaryHandler.ThrowOnSend = new InvalidOperationException( + "JavaScript interop calls cannot be issued at this time."); + + var feature = _serviceProvider.GetServices().OfType().Single(); + await feature.InitializeAsync(); + + var environments = _serviceProvider.GetRequiredService(); + Assert.Empty(environments.Environments); + Assert.Contains(_serviceProvider.GetRequiredService().AppBarElements, _ => true); + } + [Fact] public async Task SwitchingEnvironment_UpdatesAccessorUrlUsedByDefaultProvider() { @@ -165,12 +231,19 @@ protected override Task SendAsync(HttpRequestMessage reques private sealed class RecordingHandler : HttpMessageHandler { public HttpRequestMessage? LastRequest { get; private set; } + public int RequestCount { get; private set; } public HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK; public string ResponseContent { get; set; } = """{ "environments": [], "defaultEnvironmentName": null }"""; + public Exception? ThrowOnSend { get; set; } protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { LastRequest = request; + RequestCount++; + + if (ThrowOnSend is not null) + throw ThrowOnSend; + return Task.FromResult(new HttpResponseMessage(StatusCode) { RequestMessage = request, diff --git a/src/modules/Elsa.Studio.Environments/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.Studio.Environments/Extensions/ServiceCollectionExtensions.cs index 865a0ca57f..9cdbdb6c73 100644 --- a/src/modules/Elsa.Studio.Environments/Extensions/ServiceCollectionExtensions.cs +++ b/src/modules/Elsa.Studio.Environments/Extensions/ServiceCollectionExtensions.cs @@ -1,7 +1,6 @@ using Elsa.Studio.Contracts; using Elsa.Studio.Environments.Contracts; using Elsa.Studio.Environments.Services; -using Elsa.Studio.Environments.Tasks; using Elsa.Studio.Extensions; using Elsa.Studio.Models; using Microsoft.Extensions.DependencyInjection; @@ -20,13 +19,16 @@ public static class ServiceCollectionExtensions /// /// Environment switching updates so /// keeps minting clients. + /// Environments load from after the app shell has a + /// circuit and user context. They are not registered as so Blazor + /// Server host startup does not call the environments API (or JS interop) during + /// Host.StartAsync. /// public static IServiceCollection AddEnvironmentsModule(this IServiceCollection services, BackendApiConfig? backendApiConfig = null) { services.AddScoped(); services.Replace(ServiceDescriptor.Scoped()); - services.AddScoped(); - services.AddScoped(sp => sp.GetRequiredService()); + services.AddScoped(); services.AddScoped(); services.AddRemoteApi(backendApiConfig); return services; diff --git a/src/modules/Elsa.Studio.Environments/Module.cs b/src/modules/Elsa.Studio.Environments/Module.cs index c2aaed199e..9809fd10a4 100644 --- a/src/modules/Elsa.Studio.Environments/Module.cs +++ b/src/modules/Elsa.Studio.Environments/Module.cs @@ -1,11 +1,8 @@ -using System.Net; using Elsa.Studio.Abstractions; using Elsa.Studio.Contracts; using Elsa.Studio.Environments.Components; -using Elsa.Studio.Environments.Tasks; +using Elsa.Studio.Environments.Services; using Elsa.Studio.Models; -using Microsoft.Extensions.Logging; -using Refit; namespace Elsa.Studio.Environments; @@ -14,28 +11,13 @@ namespace Elsa.Studio.Environments; /// public class Feature( IAppBarService appBarService, - LoadEnvironmentsStartupTask loadEnvironmentsStartupTask, - ILogger logger) : FeatureBase + EnvironmentLoader environmentLoader) : FeatureBase { /// public override async ValueTask InitializeAsync(CancellationToken cancellationToken = default) { - try - { - // Circuit-scoped load so Blazor Server pickers see the same IEnvironmentService instance. - await loadEnvironmentsStartupTask.LoadAsync(cancellationToken); - } - catch (HttpRequestException exception) - { - logger.LogWarning(exception, "Could not reach the environments API; the picker will stay empty."); - } - catch (ApiException exception) when (exception.StatusCode is HttpStatusCode.NotFound - or HttpStatusCode.Unauthorized - or HttpStatusCode.Forbidden) - { - logger.LogWarning("Environments API returned {StatusCode}; the picker will stay empty.", (int)exception.StatusCode); - } - + // After authentication in the app shell (MainLayout), when a circuit and user context exist. + await environmentLoader.EnsureLoadedAsync(cancellationToken); appBarService.AddElement(new AppBarElement()); } } diff --git a/src/modules/Elsa.Studio.Environments/Services/EnvironmentLoader.cs b/src/modules/Elsa.Studio.Environments/Services/EnvironmentLoader.cs new file mode 100644 index 0000000000..40145f7788 --- /dev/null +++ b/src/modules/Elsa.Studio.Environments/Services/EnvironmentLoader.cs @@ -0,0 +1,57 @@ +using System.Net; +using Elsa.Studio.Contracts; +using Elsa.Studio.Environments.Contracts; +using Microsoft.Extensions.Logging; +using Refit; + +namespace Elsa.Studio.Environments.Services; + +/// +/// Loads available environments from the current backend into . +/// +/// +/// This is circuit-scoped work. Do not register it as IStartupTask: on Blazor Server +/// those tasks run from RunStartupTasksHostedService during Host.StartAsync, +/// before JS interop exists for ElsaIdentity token storage. +/// +public class EnvironmentLoader( + IBackendApiClientProvider backendApiClientProvider, + IEnvironmentService environmentService, + ILogger logger) +{ + private Task? _loadTask; + + /// + /// Fetches environments once for this circuit and stores them. + /// Load failures are logged and leave the picker empty. + /// + public Task EnsureLoadedAsync(CancellationToken cancellationToken = default) + { + return _loadTask ??= LoadAsync(cancellationToken); + } + + private async Task LoadAsync(CancellationToken cancellationToken) + { + try + { + var environmentsClient = await backendApiClientProvider.GetApiAsync(cancellationToken); + var response = await environmentsClient.ListEnvironmentsAsync(cancellationToken); + environmentService.SetEnvironments(response.Environments, response.DefaultEnvironmentName); + } + catch (HttpRequestException exception) + { + logger.LogWarning(exception, "Could not reach the environments API; the picker will stay empty."); + } + catch (ApiException exception) when (exception.StatusCode is HttpStatusCode.NotFound + or HttpStatusCode.Unauthorized + or HttpStatusCode.Forbidden) + { + logger.LogWarning("Environments API returned {StatusCode}; the picker will stay empty.", (int)exception.StatusCode); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + // No token yet, JS interop unavailable, or any other load failure must not crash the circuit. + logger.LogWarning(exception, "Could not load environments; the picker will stay empty."); + } + } +} diff --git a/src/modules/Elsa.Studio.Environments/Tasks/LoadEnvironmentsStartupTask.cs b/src/modules/Elsa.Studio.Environments/Tasks/LoadEnvironmentsStartupTask.cs deleted file mode 100644 index 8e9d62fb3c..0000000000 --- a/src/modules/Elsa.Studio.Environments/Tasks/LoadEnvironmentsStartupTask.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Net; -using Elsa.Studio.Contracts; -using Elsa.Studio.Environments.Contracts; -using Microsoft.Extensions.Logging; -using Refit; - -namespace Elsa.Studio.Environments.Tasks; - -/// -/// Loads available environments from the current backend into . -/// -public class LoadEnvironmentsStartupTask( - IBackendApiClientProvider backendApiClientProvider, - IEnvironmentService environmentService, - ILogger logger) : IStartupTask -{ - /// - /// Fetches environments through and stores them. - /// - public async ValueTask LoadAsync(CancellationToken cancellationToken = default) - { - var environmentsClient = await backendApiClientProvider.GetApiAsync(cancellationToken); - var response = await environmentsClient.ListEnvironmentsAsync(cancellationToken); - environmentService.SetEnvironments(response.Environments, response.DefaultEnvironmentName); - } - - /// - public async ValueTask ExecuteAsync(CancellationToken cancellationToken = default) - { - try - { - await LoadAsync(cancellationToken); - } - catch (HttpRequestException exception) - { - // Hosted-service startup can run before the circuit has a backend connection. - logger.LogWarning(exception, "Could not reach the environments API during startup."); - } - catch (ApiException exception) when (exception.StatusCode is HttpStatusCode.NotFound - or HttpStatusCode.Unauthorized - or HttpStatusCode.Forbidden) - { - logger.LogWarning("Environments API returned {StatusCode} during startup; environments will load after login if the API is available.", (int)exception.StatusCode); - } - } -} From 44e502f56d5203e33e40640f93813ec7d2298268 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 25 Sep 2026 10:52:14 +0000 Subject: [PATCH 029/169] fix(environments): retry failed loads and select default env before catalog Do not cache a failed or timed-out EnvironmentLoader attempt, treat HttpClient timeouts as logged load failures, and decorate IRemoteFeatureProvider so ListAsync waits for the default environment before the remote feature catalog is fetched. Co-authored-by: Sipke Schoorstra --- .../Elsa.Studio.Environments.Tests.csproj | 2 + .../ElsaIdentityStartupTests.cs | 94 ++++++++++++ .../EnvironmentsModuleTests.cs | 86 ++++++++++- .../FeatureCatalogTests.cs | 143 ++++++++++++++++++ .../Extensions/ServiceCollectionExtensions.cs | 31 +++- .../EnvironmentAwareRemoteFeatureProvider.cs | 26 ++++ .../Services/EnvironmentLoader.cs | 15 +- 7 files changed, 388 insertions(+), 9 deletions(-) create mode 100644 src/modules/Elsa.Studio.Environments.Tests/ElsaIdentityStartupTests.cs create mode 100644 src/modules/Elsa.Studio.Environments.Tests/FeatureCatalogTests.cs create mode 100644 src/modules/Elsa.Studio.Environments/Services/EnvironmentAwareRemoteFeatureProvider.cs diff --git a/src/modules/Elsa.Studio.Environments.Tests/Elsa.Studio.Environments.Tests.csproj b/src/modules/Elsa.Studio.Environments.Tests/Elsa.Studio.Environments.Tests.csproj index 61fbf2285e..21499a50a0 100644 --- a/src/modules/Elsa.Studio.Environments.Tests/Elsa.Studio.Environments.Tests.csproj +++ b/src/modules/Elsa.Studio.Environments.Tests/Elsa.Studio.Environments.Tests.csproj @@ -15,6 +15,8 @@ + + diff --git a/src/modules/Elsa.Studio.Environments.Tests/ElsaIdentityStartupTests.cs b/src/modules/Elsa.Studio.Environments.Tests/ElsaIdentityStartupTests.cs new file mode 100644 index 0000000000..ad6b2a6e4e --- /dev/null +++ b/src/modules/Elsa.Studio.Environments.Tests/ElsaIdentityStartupTests.cs @@ -0,0 +1,94 @@ +using Elsa.Studio.Authentication.ElsaIdentity.BlazorServer.Extensions; +using Elsa.Studio.Authentication.ElsaIdentity.Contracts; +using Elsa.Studio.Authentication.ElsaIdentity.HttpMessageHandlers; +using Elsa.Studio.Contracts; +using Elsa.Studio.Core.BlazorServer.HostedServices; +using Elsa.Studio.Environments; +using Elsa.Studio.Environments.Contracts; +using Elsa.Studio.Environments.Extensions; +using Elsa.Studio.Extensions; +using Elsa.Studio.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Elsa.Studio.Environments.Tests; + +/// +/// Covers the Host.Server ElsaIdentity path for #1062: startup must not throw, and +/// Feature init must tolerate ElsaIdentity's real token read when JS interop is unavailable. +/// +public class ElsaIdentityStartupTests +{ + [Fact] + public async Task FeatureInitialize_DoesNotThrowWhenElsaIdentityTokenReadFails() + { + // Arrange + var jwtAccessor = new ThrowingJwtAccessor(); + using var provider = BuildElsaIdentityServices(jwtAccessor, registerHostedService: false); + var feature = provider.GetServices().OfType().Single(); + + // Act + await feature.InitializeAsync(); + + // Assert + Assert.True(jwtAccessor.ReadCount > 0); + Assert.Empty(provider.GetRequiredService().Environments); + Assert.NotEmpty(provider.GetRequiredService().AppBarElements); + } + + [Fact] + public async Task RunStartupTasksHostedService_DoesNotThrowWithEnvironmentsAndElsaIdentity() + { + // Arrange + using var provider = BuildElsaIdentityServices(new ThrowingJwtAccessor(), registerHostedService: true); + var hostedService = provider.GetServices().OfType().Single(); + + // Act + var exception = await Record.ExceptionAsync(() => hostedService.StartAsync(CancellationToken.None)); + + // Assert + Assert.Null(exception); + Assert.Empty(provider.GetRequiredService().Environments); + } + + private static ServiceProvider BuildElsaIdentityServices(IJwtAccessor jwtAccessor, bool registerHostedService) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddCoreInternal(); + services.AddElsaIdentity(); + services.Replace(ServiceDescriptor.Scoped(_ => jwtAccessor)); + + var backendApiConfig = new BackendApiConfig + { + ConfigureBackendOptions = options => options.Url = new Uri("https://backend.example/"), + ConfigureHttpClientBuilder = options => options.AuthenticationHandler = typeof(ElsaIdentityAuthenticatingApiHttpMessageHandler) + }; + + services.AddRemoteBackend(backendApiConfig); + services.AddEnvironmentsModule(backendApiConfig); + services.AddTransient(); + + if (registerHostedService) + services.AddHostedService(); + + return services.BuildServiceProvider(); + } + + private sealed class ThrowingJwtAccessor : IJwtAccessor + { + public int ReadCount { get; private set; } + + public ValueTask ReadTokenAsync(string name) + { + ReadCount++; + throw new InvalidOperationException("JavaScript interop calls cannot be issued at this time."); + } + + public ValueTask WriteTokenAsync(string name, string token) => ValueTask.CompletedTask; + + public ValueTask ClearTokenAsync(string name) => ValueTask.CompletedTask; + } +} diff --git a/src/modules/Elsa.Studio.Environments.Tests/EnvironmentsModuleTests.cs b/src/modules/Elsa.Studio.Environments.Tests/EnvironmentsModuleTests.cs index 642fe73ace..3187e7aeb5 100644 --- a/src/modules/Elsa.Studio.Environments.Tests/EnvironmentsModuleTests.cs +++ b/src/modules/Elsa.Studio.Environments.Tests/EnvironmentsModuleTests.cs @@ -65,8 +65,6 @@ public void AddEnvironmentsModule_KeepsDefaultBackendApiClientProvider() public void AddEnvironmentsModule_DoesNotRegisterStartupTask() { Assert.Empty(_serviceProvider.GetServices()); - Assert.Null(typeof(EnvironmentLoader).Assembly.GetType( - "Elsa.Studio.Environments.Tasks.LoadEnvironmentsStartupTask")); } [Fact] @@ -132,6 +130,77 @@ public async Task EnvironmentLoader_EnsureLoadedAsync_LoadsOnce() Assert.Contains(environments.Environments, environment => environment.Name == "Staging"); } + [Fact] + public async Task EnvironmentLoader_FailedLoad_IsNotCachedAndRetries() + { + // Arrange + _primaryHandler.ThrowOnSend = new InvalidOperationException("The environments API is unreachable."); + var loader = _serviceProvider.GetRequiredService(); + var environments = _serviceProvider.GetRequiredService(); + + // Act + await loader.EnsureLoadedAsync(); + + // Assert + Assert.Empty(environments.Environments); + var failedRequests = _primaryHandler.RequestCount; + Assert.True(failedRequests >= 1); + + // Arrange: backend recovers + _primaryHandler.ThrowOnSend = null; + _primaryHandler.ResponseContent = """ + { + "environments": [ + { "name": "Dev", "url": "https://dev.example/" } + ], + "defaultEnvironmentName": "Dev" + } + """; + + // Act + await loader.EnsureLoadedAsync(); + + // Assert + Assert.True(_primaryHandler.RequestCount > failedRequests); + Assert.Equal("Dev", environments.CurrentEnvironment?.Name); + } + + [Fact] + public async Task EnvironmentLoader_HttpClientTimeout_DoesNotThrowAndRetries() + { + // Arrange: HttpClient.Timeout surfaces as TaskCanceledException while the caller token is still open. + _primaryHandler.ThrowOnSend = new TaskCanceledException( + "The request was canceled due to the configured HttpClient.Timeout."); + var loader = _serviceProvider.GetRequiredService(); + var environments = _serviceProvider.GetRequiredService(); + + // Act + await loader.EnsureLoadedAsync(); + + // Assert + Assert.Empty(environments.Environments); + var timedOutRequests = _primaryHandler.RequestCount; + Assert.True(timedOutRequests >= 1); + + // Arrange: backend recovers + _primaryHandler.ThrowOnSend = null; + _primaryHandler.ResponseContent = """ + { + "environments": [ + { "name": "Dev", "url": "https://dev.example/" } + ], + "defaultEnvironmentName": "Dev" + } + """; + + // Act + await loader.EnsureLoadedAsync(); + + // Assert + Assert.True(_primaryHandler.RequestCount > timedOutRequests); + Assert.Equal("Dev", environments.CurrentEnvironment?.Name); + } + [Fact] public async Task FeatureInitialize_FillsEnvironmentsForThePicker() { @@ -162,7 +231,7 @@ public async Task FeatureInitialize_DoesNotThrowWhenEnvironmentsApiIsMissing() var environments = _serviceProvider.GetRequiredService(); Assert.Empty(environments.Environments); - Assert.Contains(_serviceProvider.GetRequiredService().AppBarElements, _ => true); + Assert.NotEmpty(_serviceProvider.GetRequiredService().AppBarElements); } [Fact] @@ -176,7 +245,7 @@ public async Task FeatureInitialize_DoesNotThrowWhenJavaScriptInteropIsUnavailab var environments = _serviceProvider.GetRequiredService(); Assert.Empty(environments.Environments); - Assert.Contains(_serviceProvider.GetRequiredService().AppBarElements, _ => true); + Assert.NotEmpty(_serviceProvider.GetRequiredService().AppBarElements); } [Fact] @@ -228,26 +297,31 @@ protected override Task SendAsync(HttpRequestMessage reques } } - private sealed class RecordingHandler : HttpMessageHandler + internal sealed class RecordingHandler : HttpMessageHandler { public HttpRequestMessage? LastRequest { get; private set; } public int RequestCount { get; private set; } + public List Requests { get; } = []; public HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK; public string ResponseContent { get; set; } = """{ "environments": [], "defaultEnvironmentName": null }"""; public Exception? ThrowOnSend { get; set; } + public Func? ResponseContentForRequest { get; set; } protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { LastRequest = request; RequestCount++; + Requests.Add(request); if (ThrowOnSend is not null) throw ThrowOnSend; + var content = ResponseContentForRequest?.Invoke(request) ?? ResponseContent; + return Task.FromResult(new HttpResponseMessage(StatusCode) { RequestMessage = request, - Content = new StringContent(ResponseContent, Encoding.UTF8, new MediaTypeHeaderValue("application/json")) + Content = new StringContent(content, Encoding.UTF8, new MediaTypeHeaderValue("application/json")) }); } } diff --git a/src/modules/Elsa.Studio.Environments.Tests/FeatureCatalogTests.cs b/src/modules/Elsa.Studio.Environments.Tests/FeatureCatalogTests.cs new file mode 100644 index 0000000000..152dcad1a4 --- /dev/null +++ b/src/modules/Elsa.Studio.Environments.Tests/FeatureCatalogTests.cs @@ -0,0 +1,143 @@ +using Elsa.Api.Client.Resources.Features.Contracts; +using Elsa.Studio.Attributes; +using Elsa.Studio.Contracts; +using Elsa.Studio.Environments.Contracts; +using Elsa.Studio.Environments.Extensions; +using Elsa.Studio.Extensions; +using Elsa.Studio.Models; +using Elsa.Studio.Services; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Elsa.Studio.Environments.Tests; + +/// +/// The default environment must be selected before +/// so features are gated against that environment's catalog, not the fallback backend. +/// +public class FeatureCatalogTests : IDisposable +{ + private readonly EnvironmentsModuleTests.RecordingHandler _handler = new(); + private readonly DefaultOnlyFeature _defaultOnlyFeature = new(); + private readonly FallbackOnlyFeature _fallbackOnlyFeature = new(); + private readonly ServiceProvider _serviceProvider; + + public FeatureCatalogTests() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddCoreInternal(); + + var backendApiConfig = new BackendApiConfig + { + ConfigureBackendOptions = options => options.Url = new Uri("https://backend.example/"), + ConfigureHttpClientBuilder = options => options.AuthenticationHandler = typeof(PassthroughAuthenticationHandler) + }; + + services.AddRemoteBackend(backendApiConfig); + services.AddEnvironmentsModule(backendApiConfig); + // Same order as Host.Server: Workflows registers IRemoteFeatureProvider after Environments. + services.AddScoped(); + services.AddSingleton(_defaultOnlyFeature); + services.AddSingleton(_fallbackOnlyFeature); + services.AddHttpClient(nameof(IEnvironmentsClient)).ConfigurePrimaryHttpMessageHandler(() => _handler); + services.AddHttpClient(nameof(IFeaturesApi)).ConfigurePrimaryHttpMessageHandler(() => _handler); + + _handler.ResponseContentForRequest = request => + { + var url = request.RequestUri?.ToString() ?? string.Empty; + if (url.Contains("/environments", StringComparison.OrdinalIgnoreCase)) + { + return """ + { + "environments": [ + { "name": "Fallback", "url": "https://backend.example/" }, + { "name": "Dev", "url": "https://dev.example/" } + ], + "defaultEnvironmentName": "Dev" + } + """; + } + + if (url.Contains("dev.example", StringComparison.OrdinalIgnoreCase)) + { + return """ + { + "items": [ + { "fullName": "Elsa.DefaultOnly", "name": "DefaultOnly", "namespace": "Elsa" } + ], + "totalCount": 1 + } + """; + } + + return """ + { + "items": [ + { "fullName": "Elsa.FallbackOnly", "name": "FallbackOnly", "namespace": "Elsa" } + ], + "totalCount": 1 + } + """; + }; + + _serviceProvider = services.BuildServiceProvider(); + } + + public void Dispose() => _serviceProvider.Dispose(); + + [Fact] + public async Task InitializeFeatures_UsesTheDefaultEnvironmentCatalog() + { + // Arrange + var features = _serviceProvider.GetRequiredService(); + var environments = _serviceProvider.GetRequiredService(); + + // Act + await features.InitializeFeaturesAsync(); + + // Assert + Assert.Equal("Dev", environments.CurrentEnvironment?.Name); + Assert.Equal(new Uri("https://dev.example/"), environments.CurrentEnvironment?.Url); + Assert.True(_defaultOnlyFeature.Initialized); + Assert.False(_fallbackOnlyFeature.Initialized); + Assert.Contains(_handler.Requests, request => + request.RequestUri is not null + && request.RequestUri.Host == "dev.example" + && request.RequestUri.AbsolutePath.Contains("features", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(_handler.Requests, request => + request.RequestUri is not null + && request.RequestUri.Host == "backend.example" + && request.RequestUri.AbsolutePath.Contains("features", StringComparison.OrdinalIgnoreCase)); + } + + [RemoteFeature("Elsa.DefaultOnly")] + private sealed class DefaultOnlyFeature : IFeature + { + public bool Initialized { get; private set; } + + public ValueTask InitializeAsync(CancellationToken cancellationToken = default) + { + Initialized = true; + return ValueTask.CompletedTask; + } + } + + [RemoteFeature("Elsa.FallbackOnly")] + private sealed class FallbackOnlyFeature : IFeature + { + public bool Initialized { get; private set; } + + public ValueTask InitializeAsync(CancellationToken cancellationToken = default) + { + Initialized = true; + return ValueTask.CompletedTask; + } + } + + private sealed class PassthroughAuthenticationHandler : DelegatingHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + base.SendAsync(request, cancellationToken); + } +} diff --git a/src/modules/Elsa.Studio.Environments/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.Studio.Environments/Extensions/ServiceCollectionExtensions.cs index 9cdbdb6c73..0029f6ebef 100644 --- a/src/modules/Elsa.Studio.Environments/Extensions/ServiceCollectionExtensions.cs +++ b/src/modules/Elsa.Studio.Environments/Extensions/ServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using Elsa.Studio.Environments.Services; using Elsa.Studio.Extensions; using Elsa.Studio.Models; +using Elsa.Studio.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -18,11 +19,14 @@ public static class ServiceCollectionExtensions /// /// /// Environment switching updates so - /// keeps minting clients. + /// keeps minting clients. /// Environments load from after the app shell has a - /// circuit and user context. They are not registered as so Blazor + /// circuit and user context. They are not registered as so Blazor /// Server host startup does not call the environments API (or JS interop) during /// Host.StartAsync. + /// waits for + /// so the default environment is selected + /// before the remote feature catalog is fetched. /// public static IServiceCollection AddEnvironmentsModule(this IServiceCollection services, BackendApiConfig? backendApiConfig = null) { @@ -31,6 +35,29 @@ public static IServiceCollection AddEnvironmentsModule(this IServiceCollection s services.AddScoped(); services.AddScoped(); services.AddRemoteApi(backendApiConfig); + DecorateRemoteFeatureProvider(services); return services; } + + private static void DecorateRemoteFeatureProvider(IServiceCollection services) + { + services.TryAddScoped(); + services.Replace(ServiceDescriptor.Scoped(sp => + new EnvironmentAwareRemoteFeatureProvider( + sp.GetRequiredService(), + sp.GetRequiredService()))); + + // DefaultFeatureService resolves a single IRemoteFeatureProvider (last registration). + // Workflows may add another after this module; wrap whatever is resolved at that point. + services.Replace(ServiceDescriptor.Scoped(sp => + { + var loader = sp.GetRequiredService(); + var remote = sp.GetService() + ?? sp.GetRequiredService(); + if (remote is not EnvironmentAwareRemoteFeatureProvider) + remote = new EnvironmentAwareRemoteFeatureProvider(remote, loader); + + return new DefaultFeatureService(sp.GetServices(), remote); + })); + } } diff --git a/src/modules/Elsa.Studio.Environments/Services/EnvironmentAwareRemoteFeatureProvider.cs b/src/modules/Elsa.Studio.Environments/Services/EnvironmentAwareRemoteFeatureProvider.cs new file mode 100644 index 0000000000..eab1b83e1e --- /dev/null +++ b/src/modules/Elsa.Studio.Environments/Services/EnvironmentAwareRemoteFeatureProvider.cs @@ -0,0 +1,26 @@ +using Elsa.Api.Client.Resources.Features.Models; +using Elsa.Studio.Contracts; + +namespace Elsa.Studio.Environments.Services; + +/// +/// Selects the default environment before the remote feature catalog is fetched. +/// +internal sealed class EnvironmentAwareRemoteFeatureProvider( + IRemoteFeatureProvider inner, + EnvironmentLoader environmentLoader) : IRemoteFeatureProvider +{ + /// + public async Task IsEnabledAsync(string featureName, CancellationToken cancellationToken = default) + { + await environmentLoader.EnsureLoadedAsync(cancellationToken); + return await inner.IsEnabledAsync(featureName, cancellationToken); + } + + /// + public async Task> ListAsync(CancellationToken cancellationToken = default) + { + await environmentLoader.EnsureLoadedAsync(cancellationToken); + return await inner.ListAsync(cancellationToken); + } +} diff --git a/src/modules/Elsa.Studio.Environments/Services/EnvironmentLoader.cs b/src/modules/Elsa.Studio.Environments/Services/EnvironmentLoader.cs index 40145f7788..ae23299070 100644 --- a/src/modules/Elsa.Studio.Environments/Services/EnvironmentLoader.cs +++ b/src/modules/Elsa.Studio.Environments/Services/EnvironmentLoader.cs @@ -23,7 +23,8 @@ public class EnvironmentLoader( /// /// Fetches environments once for this circuit and stores them. - /// Load failures are logged and leave the picker empty. + /// Successful loads are cached. Failures are logged, leave the picker empty, + /// and are not cached so the next call tries again. /// public Task EnsureLoadedAsync(CancellationToken cancellationToken = default) { @@ -32,11 +33,18 @@ public Task EnsureLoadedAsync(CancellationToken cancellationToken = default) private async Task LoadAsync(CancellationToken cancellationToken) { + var succeeded = false; + try { var environmentsClient = await backendApiClientProvider.GetApiAsync(cancellationToken); var response = await environmentsClient.ListEnvironmentsAsync(cancellationToken); environmentService.SetEnvironments(response.Environments, response.DefaultEnvironmentName); + succeeded = true; + } + catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested) + { + logger.LogWarning(exception, "Timed out loading environments; the picker will stay empty."); } catch (HttpRequestException exception) { @@ -53,5 +61,10 @@ or HttpStatusCode.Unauthorized // No token yet, JS interop unavailable, or any other load failure must not crash the circuit. logger.LogWarning(exception, "Could not load environments; the picker will stay empty."); } + finally + { + if (!succeeded) + _loadTask = null; + } } } From 869f45fb911aa9d8e167531b0dc5b52e093da68b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 25 Sep 2026 10:53:13 +0000 Subject: [PATCH 030/169] fix(environments): cache EnvironmentLoader only after a successful load Assigning _loadTask with ??= re-cached a synchronously completed failure. Keep the task only after SetEnvironments succeeds so a later call retries. Co-authored-by: Sipke Schoorstra --- .../Services/EnvironmentLoader.cs | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/src/modules/Elsa.Studio.Environments/Services/EnvironmentLoader.cs b/src/modules/Elsa.Studio.Environments/Services/EnvironmentLoader.cs index ae23299070..b164d95287 100644 --- a/src/modules/Elsa.Studio.Environments/Services/EnvironmentLoader.cs +++ b/src/modules/Elsa.Studio.Environments/Services/EnvironmentLoader.cs @@ -26,21 +26,17 @@ public class EnvironmentLoader( /// Successful loads are cached. Failures are logged, leave the picker empty, /// and are not cached so the next call tries again. /// - public Task EnsureLoadedAsync(CancellationToken cancellationToken = default) - { - return _loadTask ??= LoadAsync(cancellationToken); - } + public Task EnsureLoadedAsync(CancellationToken cancellationToken = default) => + _loadTask ?? LoadAsync(cancellationToken); private async Task LoadAsync(CancellationToken cancellationToken) { - var succeeded = false; - try { var environmentsClient = await backendApiClientProvider.GetApiAsync(cancellationToken); var response = await environmentsClient.ListEnvironmentsAsync(cancellationToken); environmentService.SetEnvironments(response.Environments, response.DefaultEnvironmentName); - succeeded = true; + _loadTask = Task.CompletedTask; } catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested) { @@ -61,10 +57,5 @@ or HttpStatusCode.Unauthorized // No token yet, JS interop unavailable, or any other load failure must not crash the circuit. logger.LogWarning(exception, "Could not load environments; the picker will stay empty."); } - finally - { - if (!succeeded) - _loadTask = null; - } } } From c8ed24f38e15fac3d96a661e08f0d53d3a1f1e25 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 25 Sep 2026 10:54:49 +0000 Subject: [PATCH 031/169] fix(environments): log circuit teardown at Debug in EnvironmentLoader JSDisconnectedException and ObjectDisposedException are expected when the circuit goes away; keep other load failures at Warning. Co-authored-by: Sipke Schoorstra --- .../Elsa.Studio.Environments/Services/EnvironmentLoader.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/modules/Elsa.Studio.Environments/Services/EnvironmentLoader.cs b/src/modules/Elsa.Studio.Environments/Services/EnvironmentLoader.cs index b164d95287..83793a3e72 100644 --- a/src/modules/Elsa.Studio.Environments/Services/EnvironmentLoader.cs +++ b/src/modules/Elsa.Studio.Environments/Services/EnvironmentLoader.cs @@ -2,6 +2,7 @@ using Elsa.Studio.Contracts; using Elsa.Studio.Environments.Contracts; using Microsoft.Extensions.Logging; +using Microsoft.JSInterop; using Refit; namespace Elsa.Studio.Environments.Services; @@ -52,6 +53,10 @@ or HttpStatusCode.Unauthorized { logger.LogWarning("Environments API returned {StatusCode}; the picker will stay empty.", (int)exception.StatusCode); } + catch (Exception exception) when (exception is JSDisconnectedException or ObjectDisposedException) + { + logger.LogDebug(exception, "Could not load environments; the picker will stay empty."); + } catch (Exception exception) when (exception is not OperationCanceledException) { // No token yet, JS interop unavailable, or any other load failure must not crash the circuit. From 8869132fad1701918ef2c2ecb6097e529645d753 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 25 Sep 2026 11:10:04 +0000 Subject: [PATCH 032/169] fix(persistence): refuse Finished rows in TryMarkInterruptedAsync Dapper, Elasticsearch and Mongo now ignore allowFinishedCancelled and only mark a Running row Interrupted, matching core#8419/#8421. Fixes #225 Co-authored-by: Sipke Schoorstra --- .../Stores/DapperWorkflowInstanceStore.cs | 19 ++++------ .../Management/WorkflowInstanceStore.cs | 37 ++++++------------- .../Management/WorkflowInstanceStore.cs | 15 +++----- .../DapperWorkflowInstanceStoreTests.cs | 10 ++--- .../TryMarkInterruptedQueryTests.cs | 18 --------- .../MongoWorkflowInstanceStoreTests.cs | 28 +++----------- 6 files changed, 35 insertions(+), 92 deletions(-) diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowInstanceStore.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowInstanceStore.cs index de51fd8179..d36a7e568b 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowInstanceStore.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowInstanceStore.cs @@ -171,6 +171,10 @@ public async Task UpdateUpdatedTimestampAsync(string workflowInstanceId, DateTim } /// + /// + /// is unused: drain no longer promotes Finished/Cancelled (#8419). + /// The parameter remains so the 3.8.4 signature stays binary-compatible. + /// public async ValueTask TryMarkInterruptedAsync(string workflowInstanceId, CancellationToken cancellationToken = default, bool allowFinishedCancelled = false) { var record = new WorkflowInstanceRecord @@ -187,19 +191,10 @@ public async ValueTask TryMarkInterruptedAsync(string workflowInstanceId, q => { q.Is(nameof(WorkflowInstanceRecord.Id), workflowInstanceId); - // Default: refuse every Finished row (#8052). Distinct param names avoid - // colliding with SET Status = Running. Drain PersistInterrupted alone may - // pass allowFinishedCancelled (elsa-core#8069). + // Refuse every Finished row (#8419). Distinct param names avoid colliding + // with SET Status = Running. q.Parameters.Add("@FinishedStatus", WorkflowStatus.Finished.ToString()); - if (allowFinishedCancelled) - { - q.Sql.AppendLine("and (not Status = @FinishedStatus or SubStatus = @CancelledSubStatus)"); - q.Parameters.Add("@CancelledSubStatus", WorkflowSubStatus.Cancelled.ToString()); - } - else - { - q.Sql.AppendLine("and not Status = @FinishedStatus"); - } + q.Sql.AppendLine("and not Status = @FinishedStatus"); }, cancellationToken); diff --git a/src/modules/persistence/Elsa.Persistence.Elasticsearch/Modules/Management/WorkflowInstanceStore.cs b/src/modules/persistence/Elsa.Persistence.Elasticsearch/Modules/Management/WorkflowInstanceStore.cs index 4d55fccbb0..5a5ec15004 100644 --- a/src/modules/persistence/Elsa.Persistence.Elasticsearch/Modules/Management/WorkflowInstanceStore.cs +++ b/src/modules/persistence/Elsa.Persistence.Elasticsearch/Modules/Management/WorkflowInstanceStore.cs @@ -151,38 +151,23 @@ public Task UpdateUpdatedTimestampAsync(string workflowInstanceId, DateTimeOffse } /// + /// + /// is unused: drain no longer promotes Finished/Cancelled (#8419). + /// The parameter remains so the 3.8.4 signature stays binary-compatible. + /// public async ValueTask TryMarkInterruptedAsync(string workflowInstanceId, CancellationToken cancellationToken = default, bool allowFinishedCancelled = false) { // Conditional write: do not SaveAsync a Find snapshot. Id is a document field - // (not mapped as _id), so Update-by-id is unavailable. Default UpdateByQuery - // refuses every Finished row (#8052). Drain PersistInterrupted alone may pass - // allowFinishedCancelled to add Status != Finished OR SubStatus == Cancelled. + // (not mapped as _id), so Update-by-id is unavailable. Refuse every Finished + // row (#8419). allowFinishedCancelled is ignored. var updated = await _store.UpdateByQueryAsync(d => { d.Refresh(true); - if (allowFinishedCancelled) - { - d.Query(q => q.Bool(b => b - .Must( - m => m.Match(mt => mt.Field(f => f.Id).Query(workflowInstanceId)), - m => m.Bool(s => s - .Should( - sh => sh.Bool(n => n.MustNot(mn => mn.Match(mt => mt - .Field(f => f.Status) - .Query(WorkflowStatus.Finished.ToString()!)))), - sh => sh.Match(mt => mt - .Field(f => f.SubStatus) - .Query(WorkflowSubStatus.Cancelled.ToString()!))) - .MinimumShouldMatch(1))))); - } - else - { - d.Query(q => q.Bool(b => b - .Must(m => m.Match(mt => mt.Field(f => f.Id).Query(workflowInstanceId))) - .MustNot(mn => mn.Match(mt => mt - .Field(f => f.Status) - .Query(WorkflowStatus.Finished.ToString()!))))); - } + d.Query(q => q.Bool(b => b + .Must(m => m.Match(mt => mt.Field(f => f.Id).Query(workflowInstanceId))) + .MustNot(mn => mn.Match(mt => mt + .Field(f => f.Status) + .Query(WorkflowStatus.Finished.ToString()!))))); d.Script(s => s .Source("ctx._source.status = params.status; ctx._source.subStatus = params.subStatus; ctx._source.isExecuting = params.isExecuting;") diff --git a/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowInstanceStore.cs b/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowInstanceStore.cs index 5e96c0df08..03c1db3baa 100644 --- a/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowInstanceStore.cs +++ b/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowInstanceStore.cs @@ -139,20 +139,17 @@ public async Task UpdateUpdatedTimestampAsync(string workflowInstanceId, DateTim } /// + /// + /// is unused: drain no longer promotes Finished/Cancelled (#8419). + /// The parameter remains so the 3.8.4 signature stays binary-compatible. + /// public async ValueTask TryMarkInterruptedAsync(string workflowInstanceId, CancellationToken cancellationToken = default, bool allowFinishedCancelled = false) { var collection = mongoDbStore.GetCollection(); - // Default: refuse every Finished row (#8052). Drain PersistInterrupted alone may pass - // allowFinishedCancelled to promote Finished/Cancelled → Running+Interrupted. + // Refuse every Finished row (#8419). allowFinishedCancelled is ignored. var idFilter = Builders.Filter.Eq(x => x.Id, workflowInstanceId); var notFinished = Builders.Filter.Ne(x => x.Status, WorkflowStatus.Finished); - var filter = allowFinishedCancelled - ? Builders.Filter.And( - idFilter, - Builders.Filter.Or( - notFinished, - Builders.Filter.Eq(x => x.SubStatus, WorkflowSubStatus.Cancelled))) - : Builders.Filter.And(idFilter, notFinished); + var filter = Builders.Filter.And(idFilter, notFinished); var update = Builders.Update .Set(x => x.Status, WorkflowStatus.Running) .Set(x => x.SubStatus, WorkflowSubStatus.Interrupted) diff --git a/test/modules/persistence/Elsa.Dapper.UnitTests/DapperWorkflowInstanceStoreTests.cs b/test/modules/persistence/Elsa.Dapper.UnitTests/DapperWorkflowInstanceStoreTests.cs index 703de298cf..72652b77a6 100644 --- a/test/modules/persistence/Elsa.Dapper.UnitTests/DapperWorkflowInstanceStoreTests.cs +++ b/test/modules/persistence/Elsa.Dapper.UnitTests/DapperWorkflowInstanceStoreTests.cs @@ -118,7 +118,7 @@ public async Task TryMarkInterruptedAsync_MarksRunningInstance() Assert.Equal(("Running", "Interrupted", 0), ReadMarkers("running-1")); } - [Fact(DisplayName = "TryMarkInterruptedAsync refuses Finished/Cancelled unless allowFinishedCancelled is set")] + [Fact(DisplayName = "TryMarkInterruptedAsync refuses Finished/Cancelled")] public async Task TryMarkInterruptedAsync_DoesNotOverwriteCancelledInstanceByDefault() { InsertInterruptible("cancelled-1", WorkflowStatus.Finished, WorkflowSubStatus.Cancelled, isExecuting: false); @@ -130,16 +130,16 @@ public async Task TryMarkInterruptedAsync_DoesNotOverwriteCancelledInstanceByDef Assert.Equal(("Finished", "Cancelled", 0), ReadMarkers("cancelled-1")); } - [Fact(DisplayName = "TryMarkInterruptedAsync promotes Finished/Cancelled only when allowFinishedCancelled is true")] - public async Task TryMarkInterruptedAsync_PromotesCancelledWhenAllowed() + [Fact(DisplayName = "TryMarkInterruptedAsync refuses Finished/Cancelled even when allowFinishedCancelled is true")] + public async Task TryMarkInterruptedAsync_RefusesCancelledWhenAllowed() { InsertInterruptible("cancelled-1", WorkflowStatus.Finished, WorkflowSubStatus.Cancelled, isExecuting: false); using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); var marked = await _store.TryMarkInterruptedAsync("cancelled-1", allowFinishedCancelled: true); - Assert.True(marked); - Assert.Equal(("Running", "Interrupted", 0), ReadMarkers("cancelled-1")); + Assert.False(marked); + Assert.Equal(("Finished", "Cancelled", 0), ReadMarkers("cancelled-1")); } [Fact(DisplayName = "TryMarkInterruptedAsync refuses Finished/Finished")] diff --git a/test/modules/persistence/Elsa.Dapper.UnitTests/TryMarkInterruptedQueryTests.cs b/test/modules/persistence/Elsa.Dapper.UnitTests/TryMarkInterruptedQueryTests.cs index d46b06009e..b59114bef5 100644 --- a/test/modules/persistence/Elsa.Dapper.UnitTests/TryMarkInterruptedQueryTests.cs +++ b/test/modules/persistence/Elsa.Dapper.UnitTests/TryMarkInterruptedQueryTests.cs @@ -24,24 +24,6 @@ public void UpdateQuery_RefusesFinishedByDefault() Assert.Equal(WorkflowStatus.Finished.ToString(), query.Parameters.Get("FinishedStatus")); } - [Fact(DisplayName = "Conditional interrupt update allows Running or Finished/Cancelled when flag is set")] - public void UpdateQuery_AllowsRunningOrFinishedCancelledWhenFlagSet() - { - var query = CreateInterruptUpdate(); - query.Sql.AppendLine("and (not Status = @FinishedStatus or SubStatus = @CancelledSubStatus)"); - query.Parameters.Add("@FinishedStatus", WorkflowStatus.Finished.ToString()); - query.Parameters.Add("@CancelledSubStatus", WorkflowSubStatus.Cancelled.ToString()); - - var sql = query.Sql.ToString(); - - Assert.Contains("UPDATE WorkflowInstances SET Status = @Status, SubStatus = @SubStatus, IsExecuting = @IsExecuting WHERE 1=1", sql, StringComparison.Ordinal); - Assert.Contains("and Id = @Id", sql, StringComparison.Ordinal); - Assert.Contains("and (not Status = @FinishedStatus or SubStatus = @CancelledSubStatus)", sql, StringComparison.Ordinal); - AssertSharedInterruptParameters(query); - Assert.Equal(WorkflowStatus.Finished.ToString(), query.Parameters.Get("FinishedStatus")); - Assert.Equal(WorkflowSubStatus.Cancelled.ToString(), query.Parameters.Get("CancelledSubStatus")); - } - private static ParameterizedQuery CreateInterruptUpdate() { var record = new diff --git a/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoWorkflowInstanceStoreTests.cs b/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoWorkflowInstanceStoreTests.cs index 17c3d914f8..e8dccfa991 100644 --- a/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoWorkflowInstanceStoreTests.cs +++ b/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoWorkflowInstanceStoreTests.cs @@ -28,7 +28,7 @@ await collection.Received(1).UpdateOneAsync( Arg.Any()); } - [Fact(DisplayName = "TryMarkInterruptedAsync refuses Finished/Cancelled unless allowFinishedCancelled is set")] + [Fact(DisplayName = "TryMarkInterruptedAsync refuses Finished/Cancelled")] public async Task TryMarkInterruptedAsync_FilterRefusesFinishedCancelledByDefault() { var (store, collection) = CreateStore(matchedCount: 0); @@ -43,16 +43,16 @@ await collection.Received(1).UpdateOneAsync( Arg.Any()); } - [Fact(DisplayName = "TryMarkInterruptedAsync filter allows Finished/Cancelled only when allowFinishedCancelled is true")] - public async Task TryMarkInterruptedAsync_FilterAllowsFinishedCancelledWhenFlagSet() + [Fact(DisplayName = "TryMarkInterruptedAsync still refuses Finished/Cancelled when allowFinishedCancelled is true")] + public async Task TryMarkInterruptedAsync_FilterRefusesFinishedCancelledWhenFlagSet() { - var (store, collection) = CreateStore(matchedCount: 1); + var (store, collection) = CreateStore(matchedCount: 0); var marked = await store.TryMarkInterruptedAsync("cancelled-1", allowFinishedCancelled: true); - Assert.True(marked); + Assert.False(marked); await collection.Received(1).UpdateOneAsync( - Arg.Is>(filter => FilterAllowsFinishedCancelled(filter, "cancelled-1")), + Arg.Is>(filter => FilterRefusesFinished(filter, "cancelled-1")), Arg.Is>(update => UpdatePromotesToRunningInterrupted(update)), Arg.Any(), Arg.Any()); @@ -122,22 +122,6 @@ private static bool FilterRefusesFinished(FilterDefinition fil && !rendered.Contains(((int)WorkflowSubStatus.Cancelled).ToString(), StringComparison.Ordinal); } - /// - /// Flag-true filter: Id match plus Status != Finished OR SubStatus == Cancelled. - /// Still refuses Finished/Finished and Finished/Faulted at the filter. - /// - private static bool FilterAllowsFinishedCancelled(FilterDefinition filter, string id) - { - var rendered = Render(filter); - return rendered.Contains(id, StringComparison.Ordinal) - && rendered.Contains("$or", StringComparison.Ordinal) - && rendered.Contains("Status", StringComparison.Ordinal) - && rendered.Contains("$ne", StringComparison.Ordinal) - && rendered.Contains(((int)WorkflowStatus.Finished).ToString(), StringComparison.Ordinal) - && rendered.Contains("SubStatus", StringComparison.Ordinal) - && rendered.Contains(((int)WorkflowSubStatus.Cancelled).ToString(), StringComparison.Ordinal); - } - private static bool UpdatePromotesToRunningInterrupted(UpdateDefinition update) { var rendered = Render(update); From f4add6e205ea13f32cf3c7aa03844796f25e2940 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 25 Sep 2026 11:11:41 +0000 Subject: [PATCH 033/169] fix(environments): load environments once via IFeatureService wrapper Replace the IRemoteFeatureProvider decorator and IFeatureService factory with EnvironmentAwareFeatureService. Feature init loads the default environment once, then delegates. Feature.InitializeAsync only adds the picker, so a failed load cannot switch the backend mid-pass. Co-authored-by: Sipke Schoorstra --- .../ElsaIdentityStartupTests.cs | 17 +- .../EnvironmentsModuleTests.cs | 111 ++++++++-- .../FeatureCatalogTests.cs | 192 +++++++++++------- .../Extensions/ServiceCollectionExtensions.cs | 41 +--- .../Elsa.Studio.Environments/Module.cs | 10 +- .../EnvironmentAwareFeatureService.cs | 28 +++ .../EnvironmentAwareRemoteFeatureProvider.cs | 26 --- 7 files changed, 272 insertions(+), 153 deletions(-) create mode 100644 src/modules/Elsa.Studio.Environments/Services/EnvironmentAwareFeatureService.cs delete mode 100644 src/modules/Elsa.Studio.Environments/Services/EnvironmentAwareRemoteFeatureProvider.cs diff --git a/src/modules/Elsa.Studio.Environments.Tests/ElsaIdentityStartupTests.cs b/src/modules/Elsa.Studio.Environments.Tests/ElsaIdentityStartupTests.cs index ad6b2a6e4e..7a3e9743bc 100644 --- a/src/modules/Elsa.Studio.Environments.Tests/ElsaIdentityStartupTests.cs +++ b/src/modules/Elsa.Studio.Environments.Tests/ElsaIdentityStartupTests.cs @@ -1,9 +1,9 @@ +using Elsa.Api.Client.Resources.Features.Models; using Elsa.Studio.Authentication.ElsaIdentity.BlazorServer.Extensions; using Elsa.Studio.Authentication.ElsaIdentity.Contracts; using Elsa.Studio.Authentication.ElsaIdentity.HttpMessageHandlers; using Elsa.Studio.Contracts; using Elsa.Studio.Core.BlazorServer.HostedServices; -using Elsa.Studio.Environments; using Elsa.Studio.Environments.Contracts; using Elsa.Studio.Environments.Extensions; using Elsa.Studio.Extensions; @@ -22,15 +22,14 @@ namespace Elsa.Studio.Environments.Tests; public class ElsaIdentityStartupTests { [Fact] - public async Task FeatureInitialize_DoesNotThrowWhenElsaIdentityTokenReadFails() + public async Task InitializeFeatures_DoesNotThrowWhenElsaIdentityTokenReadFails() { // Arrange var jwtAccessor = new ThrowingJwtAccessor(); using var provider = BuildElsaIdentityServices(jwtAccessor, registerHostedService: false); - var feature = provider.GetServices().OfType().Single(); // Act - await feature.InitializeAsync(); + await provider.GetRequiredService().InitializeFeaturesAsync(); // Assert Assert.True(jwtAccessor.ReadCount > 0); @@ -60,6 +59,7 @@ private static ServiceProvider BuildElsaIdentityServices(IJwtAccessor jwtAccesso services.AddCoreInternal(); services.AddElsaIdentity(); services.Replace(ServiceDescriptor.Scoped(_ => jwtAccessor)); + services.AddSingleton(); var backendApiConfig = new BackendApiConfig { @@ -77,6 +77,15 @@ private static ServiceProvider BuildElsaIdentityServices(IJwtAccessor jwtAccesso return services.BuildServiceProvider(); } + private sealed class EmptyRemoteFeatureProvider : IRemoteFeatureProvider + { + public Task IsEnabledAsync(string featureName, CancellationToken cancellationToken = default) => + Task.FromResult(false); + + public Task> ListAsync(CancellationToken cancellationToken = default) => + Task.FromResult>([]); + } + private sealed class ThrowingJwtAccessor : IJwtAccessor { public int ReadCount { get; private set; } diff --git a/src/modules/Elsa.Studio.Environments.Tests/EnvironmentsModuleTests.cs b/src/modules/Elsa.Studio.Environments.Tests/EnvironmentsModuleTests.cs index 3187e7aeb5..c3c9a29371 100644 --- a/src/modules/Elsa.Studio.Environments.Tests/EnvironmentsModuleTests.cs +++ b/src/modules/Elsa.Studio.Environments.Tests/EnvironmentsModuleTests.cs @@ -1,7 +1,9 @@ using System.Net; using System.Net.Http.Headers; using System.Text; +using Elsa.Api.Client.Resources.Features.Models; using Elsa.Studio.Contracts; +using Elsa.Studio.Environments; using Elsa.Studio.Environments.Contracts; using Elsa.Studio.Environments.Extensions; using Elsa.Studio.Environments.Models; @@ -21,7 +23,7 @@ namespace Elsa.Studio.Environments.Tests; /// /// /// Also covers #1062: Blazor Server host startup must not call the environments client. -/// The list loads once from the deferred Feature / path. +/// The list loads once from . /// public class EnvironmentsModuleTests : IDisposable { @@ -33,6 +35,7 @@ public EnvironmentsModuleTests() var services = new ServiceCollection(); services.AddLogging(); services.AddCoreInternal(); + services.AddSingleton(); var backendApiConfig = new BackendApiConfig { @@ -83,8 +86,21 @@ public async Task BlazorServerStartupPath_DoesNotCallEnvironmentsClient() } [Fact] - public async Task FeatureInitialize_LoadsEnvironmentsOnce() + public async Task FeatureInitialize_OnlyAddsThePicker() { + var feature = _serviceProvider.GetServices().OfType().Single(); + + await feature.InitializeAsync(); + + Assert.Equal(0, _primaryHandler.RequestCount); + Assert.Empty(_serviceProvider.GetRequiredService().Environments); + Assert.NotEmpty(_serviceProvider.GetRequiredService().AppBarElements); + } + + [Fact] + public async Task InitializeFeatures_LoadsEnvironmentsOnce() + { + // Arrange _primaryHandler.ResponseContent = """ { "environments": [ @@ -95,16 +111,19 @@ public async Task FeatureInitialize_LoadsEnvironmentsOnce() } """; - var feature = _serviceProvider.GetServices().OfType().Single(); + var features = _serviceProvider.GetRequiredService(); var environments = _serviceProvider.GetRequiredService(); - await feature.InitializeAsync(); - await feature.InitializeAsync(); + // Act + await features.InitializeFeaturesAsync(); + await features.InitializeFeaturesAsync(); + // Assert Assert.Equal(1, _primaryHandler.RequestCount); Assert.Equal(["Dev", "Prod"], environments.Environments.Select(environment => environment.Name)); Assert.Equal("Dev", environments.CurrentEnvironment?.Name); Assert.Equal(new Uri("https://dev.example/"), environments.CurrentEnvironment?.Url); + Assert.NotEmpty(_serviceProvider.GetRequiredService().AppBarElements); } [Fact] @@ -202,7 +221,38 @@ public async Task EnvironmentLoader_HttpClientTimeout_DoesNotThrowAndRetries() } [Fact] - public async Task FeatureInitialize_FillsEnvironmentsForThePicker() + public async Task EnvironmentLoader_CallerCancellation_PropagatesAndIsNotCached() + { + // Arrange + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var loader = _serviceProvider.GetRequiredService(); + var environments = _serviceProvider.GetRequiredService(); + + // Act / Assert + await Assert.ThrowsAnyAsync(() => loader.EnsureLoadedAsync(cts.Token)); + Assert.Empty(environments.Environments); + + // Arrange: a later uncancelled call must still load. + _primaryHandler.ResponseContent = """ + { + "environments": [ + { "name": "Dev", "url": "https://dev.example/" } + ], + "defaultEnvironmentName": "Dev" + } + """; + + // Act + await loader.EnsureLoadedAsync(); + + // Assert + Assert.True(_primaryHandler.RequestCount >= 1); + Assert.Equal("Dev", environments.CurrentEnvironment?.Name); + } + + [Fact] + public async Task InitializeFeatures_FillsEnvironmentsForThePicker() { _primaryHandler.ResponseContent = """ { @@ -213,35 +263,38 @@ public async Task FeatureInitialize_FillsEnvironmentsForThePicker() } """; - var feature = _serviceProvider.GetServices().OfType().Single(); - await feature.InitializeAsync(); + await _serviceProvider.GetRequiredService().InitializeFeaturesAsync(); var environments = _serviceProvider.GetRequiredService(); Assert.Equal("Staging", environments.CurrentEnvironment?.Name); Assert.Contains(environments.Environments, environment => environment.Name == "Staging"); + Assert.NotEmpty(_serviceProvider.GetRequiredService().AppBarElements); } [Fact] - public async Task FeatureInitialize_DoesNotThrowWhenEnvironmentsApiIsMissing() + public async Task InitializeFeatures_FailedLoad_MakesOneAttempt() { - _primaryHandler.StatusCode = HttpStatusCode.NotFound; + // Arrange + _primaryHandler.ThrowOnSend = new InvalidOperationException("The environments API is unreachable."); + var features = _serviceProvider.GetRequiredService(); + var environments = _serviceProvider.GetRequiredService(); - var feature = _serviceProvider.GetServices().OfType().Single(); - await feature.InitializeAsync(); + // Act + await features.InitializeFeaturesAsync(); - var environments = _serviceProvider.GetRequiredService(); + // Assert + Assert.Equal(1, _primaryHandler.RequestCount); Assert.Empty(environments.Environments); + Assert.Null(environments.CurrentEnvironment); Assert.NotEmpty(_serviceProvider.GetRequiredService().AppBarElements); } [Fact] - public async Task FeatureInitialize_DoesNotThrowWhenJavaScriptInteropIsUnavailable() + public async Task InitializeFeatures_DoesNotThrowWhenEnvironmentsApiIsMissing() { - _primaryHandler.ThrowOnSend = new InvalidOperationException( - "JavaScript interop calls cannot be issued at this time."); + _primaryHandler.StatusCode = HttpStatusCode.NotFound; - var feature = _serviceProvider.GetServices().OfType().Single(); - await feature.InitializeAsync(); + await _serviceProvider.GetRequiredService().InitializeFeaturesAsync(); var environments = _serviceProvider.GetRequiredService(); Assert.Empty(environments.Environments); @@ -297,6 +350,15 @@ protected override Task SendAsync(HttpRequestMessage reques } } + private sealed class EmptyRemoteFeatureProvider : IRemoteFeatureProvider + { + public Task IsEnabledAsync(string featureName, CancellationToken cancellationToken = default) => + Task.FromResult(false); + + public Task> ListAsync(CancellationToken cancellationToken = default) => + Task.FromResult>([]); + } + internal sealed class RecordingHandler : HttpMessageHandler { public HttpRequestMessage? LastRequest { get; private set; } @@ -305,10 +367,16 @@ internal sealed class RecordingHandler : HttpMessageHandler public HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK; public string ResponseContent { get; set; } = """{ "environments": [], "defaultEnvironmentName": null }"""; public Exception? ThrowOnSend { get; set; } + public int RemainingEnvironmentFailures { get; set; } public Func? ResponseContentForRequest { get; set; } + public int EnvironmentRequestCount => + Requests.Count(request => request.RequestUri?.AbsolutePath.Contains("environments", StringComparison.OrdinalIgnoreCase) == true); + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); + LastRequest = request; RequestCount++; Requests.Add(request); @@ -316,6 +384,13 @@ protected override Task SendAsync(HttpRequestMessage reques if (ThrowOnSend is not null) throw ThrowOnSend; + if (RemainingEnvironmentFailures > 0 + && request.RequestUri?.AbsolutePath.Contains("environments", StringComparison.OrdinalIgnoreCase) == true) + { + RemainingEnvironmentFailures--; + throw new InvalidOperationException("The environments API is unreachable."); + } + var content = ResponseContentForRequest?.Invoke(request) ?? ResponseContent; return Task.FromResult(new HttpResponseMessage(StatusCode) diff --git a/src/modules/Elsa.Studio.Environments.Tests/FeatureCatalogTests.cs b/src/modules/Elsa.Studio.Environments.Tests/FeatureCatalogTests.cs index 152dcad1a4..551a864c6f 100644 --- a/src/modules/Elsa.Studio.Environments.Tests/FeatureCatalogTests.cs +++ b/src/modules/Elsa.Studio.Environments.Tests/FeatureCatalogTests.cs @@ -12,17 +12,82 @@ namespace Elsa.Studio.Environments.Tests; /// -/// The default environment must be selected before +/// The default environment must be selected before the remote feature catalog is fetched /// so features are gated against that environment's catalog, not the fallback backend. /// -public class FeatureCatalogTests : IDisposable +public class FeatureCatalogTests { - private readonly EnvironmentsModuleTests.RecordingHandler _handler = new(); - private readonly DefaultOnlyFeature _defaultOnlyFeature = new(); - private readonly FallbackOnlyFeature _fallbackOnlyFeature = new(); - private readonly ServiceProvider _serviceProvider; + [Fact] + public Task InitializeFeatures_UsesDefaultCatalog_WhenEnvironmentsRegisteredFirst() => + AssertUsesDefaultCatalog(environmentsFirst: true); + + [Fact] + public Task InitializeFeatures_UsesDefaultCatalog_WhenRemoteProviderRegisteredFirst() => + AssertUsesDefaultCatalog(environmentsFirst: false); + + [Fact] + public async Task InitializeFeatures_FailedThenSucceeds_SecondPassUsesDefaultCatalog() + { + // Arrange + var defaultOnly = new DefaultOnlyFeature(); + var fallbackOnly = new FallbackOnlyFeature(); + var handler = CreateCatalogHandler(); + handler.RemainingEnvironmentFailures = 1; + using var provider = BuildServices(environmentsFirst: true, handler, defaultOnly, fallbackOnly); + var features = provider.GetRequiredService(); + var environments = provider.GetRequiredService(); + + // Act + await features.InitializeFeaturesAsync(); + + // Assert: one failed load; stay on the fallback catalog for this pass. + Assert.Equal(1, handler.EnvironmentRequestCount); + Assert.Null(environments.CurrentEnvironment); + Assert.True(fallbackOnly.Initialized); + Assert.False(defaultOnly.Initialized); + + // Act + await features.InitializeFeaturesAsync(); + + // Assert: the next pass loads Dev and gates against that catalog. + Assert.Equal(2, handler.EnvironmentRequestCount); + Assert.Equal("Dev", environments.CurrentEnvironment?.Name); + Assert.True(defaultOnly.Initialized); + } + + private static async Task AssertUsesDefaultCatalog(bool environmentsFirst) + { + // Arrange + var defaultOnly = new DefaultOnlyFeature(); + var fallbackOnly = new FallbackOnlyFeature(); + var handler = CreateCatalogHandler(); + using var provider = BuildServices(environmentsFirst, handler, defaultOnly, fallbackOnly); + var features = provider.GetRequiredService(); + var environments = provider.GetRequiredService(); - public FeatureCatalogTests() + // Act + await features.InitializeFeaturesAsync(); + + // Assert + Assert.Equal(1, handler.EnvironmentRequestCount); + Assert.Equal("Dev", environments.CurrentEnvironment?.Name); + Assert.Equal(new Uri("https://dev.example/"), environments.CurrentEnvironment?.Url); + Assert.True(defaultOnly.Initialized); + Assert.False(fallbackOnly.Initialized); + Assert.Contains(handler.Requests, request => + request.RequestUri is not null + && request.RequestUri.Host == "dev.example" + && request.RequestUri.AbsolutePath.Contains("features", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(handler.Requests, request => + request.RequestUri is not null + && request.RequestUri.Host == "backend.example" + && request.RequestUri.AbsolutePath.Contains("features", StringComparison.OrdinalIgnoreCase)); + } + + private static ServiceProvider BuildServices( + bool environmentsFirst, + EnvironmentsModuleTests.RecordingHandler handler, + params IFeature[] extraFeatures) { var services = new ServiceCollection(); services.AddLogging(); @@ -35,80 +100,71 @@ public FeatureCatalogTests() }; services.AddRemoteBackend(backendApiConfig); - services.AddEnvironmentsModule(backendApiConfig); - // Same order as Host.Server: Workflows registers IRemoteFeatureProvider after Environments. - services.AddScoped(); - services.AddSingleton(_defaultOnlyFeature); - services.AddSingleton(_fallbackOnlyFeature); - services.AddHttpClient(nameof(IEnvironmentsClient)).ConfigurePrimaryHttpMessageHandler(() => _handler); - services.AddHttpClient(nameof(IFeaturesApi)).ConfigurePrimaryHttpMessageHandler(() => _handler); - - _handler.ResponseContentForRequest = request => + + if (environmentsFirst) { - var url = request.RequestUri?.ToString() ?? string.Empty; - if (url.Contains("/environments", StringComparison.OrdinalIgnoreCase)) - { - return """ - { - "environments": [ - { "name": "Fallback", "url": "https://backend.example/" }, - { "name": "Dev", "url": "https://dev.example/" } - ], - "defaultEnvironmentName": "Dev" - } - """; - } + services.AddEnvironmentsModule(backendApiConfig); + services.AddScoped(); + } + else + { + services.AddScoped(); + services.AddEnvironmentsModule(backendApiConfig); + } - if (url.Contains("dev.example", StringComparison.OrdinalIgnoreCase)) + foreach (var feature in extraFeatures) + services.AddSingleton(feature); + + services.AddHttpClient(nameof(IEnvironmentsClient)).ConfigurePrimaryHttpMessageHandler(() => handler); + services.AddHttpClient(nameof(IFeaturesApi)).ConfigurePrimaryHttpMessageHandler(() => handler); + + return services.BuildServiceProvider(); + } + + private static EnvironmentsModuleTests.RecordingHandler CreateCatalogHandler() + { + var handler = new EnvironmentsModuleTests.RecordingHandler + { + ResponseContentForRequest = request => { + var url = request.RequestUri?.ToString() ?? string.Empty; + if (url.Contains("/environments", StringComparison.OrdinalIgnoreCase)) + { + return """ + { + "environments": [ + { "name": "Fallback", "url": "https://backend.example/" }, + { "name": "Dev", "url": "https://dev.example/" } + ], + "defaultEnvironmentName": "Dev" + } + """; + } + + if (url.Contains("dev.example", StringComparison.OrdinalIgnoreCase)) + { + return """ + { + "items": [ + { "fullName": "Elsa.DefaultOnly", "name": "DefaultOnly", "namespace": "Elsa" } + ], + "totalCount": 1 + } + """; + } + return """ { "items": [ - { "fullName": "Elsa.DefaultOnly", "name": "DefaultOnly", "namespace": "Elsa" } + { "fullName": "Elsa.FallbackOnly", "name": "FallbackOnly", "namespace": "Elsa" } ], "totalCount": 1 } """; } - - return """ - { - "items": [ - { "fullName": "Elsa.FallbackOnly", "name": "FallbackOnly", "namespace": "Elsa" } - ], - "totalCount": 1 - } - """; }; - _serviceProvider = services.BuildServiceProvider(); - } - - public void Dispose() => _serviceProvider.Dispose(); - - [Fact] - public async Task InitializeFeatures_UsesTheDefaultEnvironmentCatalog() - { - // Arrange - var features = _serviceProvider.GetRequiredService(); - var environments = _serviceProvider.GetRequiredService(); - - // Act - await features.InitializeFeaturesAsync(); - - // Assert - Assert.Equal("Dev", environments.CurrentEnvironment?.Name); - Assert.Equal(new Uri("https://dev.example/"), environments.CurrentEnvironment?.Url); - Assert.True(_defaultOnlyFeature.Initialized); - Assert.False(_fallbackOnlyFeature.Initialized); - Assert.Contains(_handler.Requests, request => - request.RequestUri is not null - && request.RequestUri.Host == "dev.example" - && request.RequestUri.AbsolutePath.Contains("features", StringComparison.OrdinalIgnoreCase)); - Assert.DoesNotContain(_handler.Requests, request => - request.RequestUri is not null - && request.RequestUri.Host == "backend.example" - && request.RequestUri.AbsolutePath.Contains("features", StringComparison.OrdinalIgnoreCase)); + return handler; } [RemoteFeature("Elsa.DefaultOnly")] diff --git a/src/modules/Elsa.Studio.Environments/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.Studio.Environments/Extensions/ServiceCollectionExtensions.cs index 0029f6ebef..2a94c1f866 100644 --- a/src/modules/Elsa.Studio.Environments/Extensions/ServiceCollectionExtensions.cs +++ b/src/modules/Elsa.Studio.Environments/Extensions/ServiceCollectionExtensions.cs @@ -20,13 +20,11 @@ public static class ServiceCollectionExtensions /// /// Environment switching updates so /// keeps minting clients. - /// Environments load from after the app shell has a - /// circuit and user context. They are not registered as so Blazor - /// Server host startup does not call the environments API (or JS interop) during - /// Host.StartAsync. - /// waits for - /// so the default environment is selected - /// before the remote feature catalog is fetched. + /// Environments load once from before + /// the remote feature catalog is fetched. They are not registered as + /// so Blazor Server host startup does not call the + /// environments API (or JS interop) during Host.StartAsync. + /// only registers the picker. /// public static IServiceCollection AddEnvironmentsModule(this IServiceCollection services, BackendApiConfig? backendApiConfig = null) { @@ -35,29 +33,12 @@ public static IServiceCollection AddEnvironmentsModule(this IServiceCollection s services.AddScoped(); services.AddScoped(); services.AddRemoteApi(backendApiConfig); - DecorateRemoteFeatureProvider(services); - return services; - } - - private static void DecorateRemoteFeatureProvider(IServiceCollection services) - { - services.TryAddScoped(); - services.Replace(ServiceDescriptor.Scoped(sp => - new EnvironmentAwareRemoteFeatureProvider( - sp.GetRequiredService(), - sp.GetRequiredService()))); - - // DefaultFeatureService resolves a single IRemoteFeatureProvider (last registration). - // Workflows may add another after this module; wrap whatever is resolved at that point. services.Replace(ServiceDescriptor.Scoped(sp => - { - var loader = sp.GetRequiredService(); - var remote = sp.GetService() - ?? sp.GetRequiredService(); - if (remote is not EnvironmentAwareRemoteFeatureProvider) - remote = new EnvironmentAwareRemoteFeatureProvider(remote, loader); - - return new DefaultFeatureService(sp.GetServices(), remote); - })); + new EnvironmentAwareFeatureService( + new DefaultFeatureService( + sp.GetServices(), + sp.GetRequiredService()), + sp.GetRequiredService()))); + return services; } } diff --git a/src/modules/Elsa.Studio.Environments/Module.cs b/src/modules/Elsa.Studio.Environments/Module.cs index 9809fd10a4..66e91771d4 100644 --- a/src/modules/Elsa.Studio.Environments/Module.cs +++ b/src/modules/Elsa.Studio.Environments/Module.cs @@ -1,7 +1,6 @@ using Elsa.Studio.Abstractions; using Elsa.Studio.Contracts; using Elsa.Studio.Environments.Components; -using Elsa.Studio.Environments.Services; using Elsa.Studio.Models; namespace Elsa.Studio.Environments; @@ -9,15 +8,12 @@ namespace Elsa.Studio.Environments; /// /// Represents the environments feature module for managing server environment selection in the app bar. /// -public class Feature( - IAppBarService appBarService, - EnvironmentLoader environmentLoader) : FeatureBase +public class Feature(IAppBarService appBarService) : FeatureBase { /// - public override async ValueTask InitializeAsync(CancellationToken cancellationToken = default) + public override ValueTask InitializeAsync(CancellationToken cancellationToken = default) { - // After authentication in the app shell (MainLayout), when a circuit and user context exist. - await environmentLoader.EnsureLoadedAsync(cancellationToken); appBarService.AddElement(new AppBarElement()); + return ValueTask.CompletedTask; } } diff --git a/src/modules/Elsa.Studio.Environments/Services/EnvironmentAwareFeatureService.cs b/src/modules/Elsa.Studio.Environments/Services/EnvironmentAwareFeatureService.cs new file mode 100644 index 0000000000..f8db2b797e --- /dev/null +++ b/src/modules/Elsa.Studio.Environments/Services/EnvironmentAwareFeatureService.cs @@ -0,0 +1,28 @@ +using Elsa.Studio.Contracts; + +namespace Elsa.Studio.Environments.Services; + +/// +/// Selects the default environment once, then initializes features against that backend. +/// +internal sealed class EnvironmentAwareFeatureService( + IFeatureService inner, + EnvironmentLoader environmentLoader) : IFeatureService +{ + /// + public event Action? Initialized + { + add => inner.Initialized += value; + remove => inner.Initialized -= value; + } + + /// + public IEnumerable GetFeatures() => inner.GetFeatures(); + + /// + public async Task InitializeFeaturesAsync(CancellationToken cancellationToken = default) + { + await environmentLoader.EnsureLoadedAsync(cancellationToken); + await inner.InitializeFeaturesAsync(cancellationToken); + } +} diff --git a/src/modules/Elsa.Studio.Environments/Services/EnvironmentAwareRemoteFeatureProvider.cs b/src/modules/Elsa.Studio.Environments/Services/EnvironmentAwareRemoteFeatureProvider.cs deleted file mode 100644 index eab1b83e1e..0000000000 --- a/src/modules/Elsa.Studio.Environments/Services/EnvironmentAwareRemoteFeatureProvider.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Elsa.Api.Client.Resources.Features.Models; -using Elsa.Studio.Contracts; - -namespace Elsa.Studio.Environments.Services; - -/// -/// Selects the default environment before the remote feature catalog is fetched. -/// -internal sealed class EnvironmentAwareRemoteFeatureProvider( - IRemoteFeatureProvider inner, - EnvironmentLoader environmentLoader) : IRemoteFeatureProvider -{ - /// - public async Task IsEnabledAsync(string featureName, CancellationToken cancellationToken = default) - { - await environmentLoader.EnsureLoadedAsync(cancellationToken); - return await inner.IsEnabledAsync(featureName, cancellationToken); - } - - /// - public async Task> ListAsync(CancellationToken cancellationToken = default) - { - await environmentLoader.EnsureLoadedAsync(cancellationToken); - return await inner.ListAsync(cancellationToken); - } -} From 1805cac61d3f46287d7013b150e36fbfbfd30ef6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 25 Sep 2026 11:23:01 +0000 Subject: [PATCH 034/169] fix(mongodb): tenant-scope TryMarkInterruptedAsync and ordered SummarizeManyAsync Route the drain update and the ordered, paged instance summary through MongoDbStore tenant filters so a tenant-B caller cannot mark or list tenant A's workflow instances. Co-authored-by: Sipke Schoorstra --- .../Common/MongoDbStore.cs | 21 ++- .../Management/WorkflowInstanceStore.cs | 13 +- ...rkflowInstanceStoreTenantIsolationTests.cs | 169 ++++++++++++++++++ 3 files changed, 193 insertions(+), 10 deletions(-) create mode 100644 test/modules/persistence/Elsa.MongoDb.UnitTests/MongoWorkflowInstanceStoreTenantIsolationTests.cs diff --git a/src/modules/persistence/Elsa.Persistence.MongoDb/Common/MongoDbStore.cs b/src/modules/persistence/Elsa.Persistence.MongoDb/Common/MongoDbStore.cs index 6372a74744..320e4e1029 100644 --- a/src/modules/persistence/Elsa.Persistence.MongoDb/Common/MongoDbStore.cs +++ b/src/modules/persistence/Elsa.Persistence.MongoDb/Common/MongoDbStore.cs @@ -180,6 +180,20 @@ public async Task UpdatePartialAsync( return updateResult.ModifiedCount > 0; } + /// + /// Updates a single document matching . Tenant scope is applied unless + /// is true. This is a single filtered update. + /// + public Task UpdateOneAsync( + FilterDefinition filter, + UpdateDefinition update, + bool tenantAgnostic = false, + CancellationToken cancellationToken = default) + { + var scopedFilter = ApplyTenantScope(filter, tenantAgnostic); + return collection.UpdateOneAsync(scopedFilter, update, cancellationToken: cancellationToken); + } + /// /// Finds the document matching the specified predicate /// @@ -488,7 +502,12 @@ private void ApplyTenantId(IEnumerable documents) } } - private FilterDefinition ApplyTenantScope(FilterDefinition filter, bool tenantAgnostic) + /// + /// ANDs the current tenant onto . No-ops when + /// is true or is not an . + /// Writes using this filter match only the ambient tenant, not tenant-agnostic ("*") rows. + /// + public FilterDefinition ApplyTenantScope(FilterDefinition filter, bool tenantAgnostic = false) { if (tenantAgnostic || !typeof(Entity).IsAssignableFrom(typeof(TDocument))) return filter; diff --git a/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowInstanceStore.cs b/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowInstanceStore.cs index 03c1db3baa..b025fbbfad 100644 --- a/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowInstanceStore.cs +++ b/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowInstanceStore.cs @@ -11,7 +11,6 @@ using JetBrains.Annotations; using Microsoft.Extensions.Logging; using MongoDB.Driver; -using MongoDB.Driver.Linq; using Open.Linq.AsyncExtensions; namespace Elsa.Persistence.MongoDb.Modules.Management; @@ -73,12 +72,8 @@ public async ValueTask> SummarizeManyAsync(Workflo /// public async ValueTask> SummarizeManyAsync(WorkflowInstanceFilter filter, PageArgs pageArgs, WorkflowInstanceOrder order, CancellationToken cancellationToken = default) { - var collection = mongoDbStore.GetCollection(); - var queryable = Order(Filter(collection.AsQueryable(), filter), order); - var count = queryable.LongCount(); - var mongoQueryable = queryable.Paginate(pageArgs); - var documents = await mongoQueryable.Select(ExpressionHelpers.WorkflowInstanceSummary).ToListAsync(cancellationToken); - + var count = await mongoDbStore.CountAsync(query => Filter(query, filter), cancellationToken); + var documents = await mongoDbStore.FindManyAsync(query => OrderAndPaginate(Filter(query, filter), order, pageArgs), ExpressionHelpers.WorkflowInstanceSummary, cancellationToken).ToList(); return Page.Of(documents, count); } @@ -145,8 +140,8 @@ public async Task UpdateUpdatedTimestampAsync(string workflowInstanceId, DateTim /// public async ValueTask TryMarkInterruptedAsync(string workflowInstanceId, CancellationToken cancellationToken = default, bool allowFinishedCancelled = false) { - var collection = mongoDbStore.GetCollection(); // Refuse every Finished row (#8419). allowFinishedCancelled is ignored. + // Single filtered update; tenant scope is applied by MongoDbStore (#228). var idFilter = Builders.Filter.Eq(x => x.Id, workflowInstanceId); var notFinished = Builders.Filter.Ne(x => x.Status, WorkflowStatus.Finished); var filter = Builders.Filter.And(idFilter, notFinished); @@ -155,7 +150,7 @@ public async ValueTask TryMarkInterruptedAsync(string workflowInstanceId, .Set(x => x.SubStatus, WorkflowSubStatus.Interrupted) .Set(x => x.IsExecuting, false); - var result = await collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken); + var result = await mongoDbStore.UpdateOneAsync(filter, update, cancellationToken: cancellationToken); return result.MatchedCount > 0; } diff --git a/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoWorkflowInstanceStoreTenantIsolationTests.cs b/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoWorkflowInstanceStoreTenantIsolationTests.cs new file mode 100644 index 0000000000..ce457f43d8 --- /dev/null +++ b/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoWorkflowInstanceStoreTenantIsolationTests.cs @@ -0,0 +1,169 @@ +using Elsa.Common.Entities; +using Elsa.Common.Models; +using Elsa.Common.Multitenancy; +using Elsa.Persistence.MongoDb.Common; +using Elsa.Persistence.MongoDb.Modules.Management; +using Elsa.Workflows; +using Elsa.Workflows.Management.Entities; +using Elsa.Workflows.Management.Filters; +using Elsa.Workflows.Management.Models; +using Elsa.Workflows.State; +using Microsoft.Extensions.Logging; +using MongoDB.Driver; +using NSubstitute; +using Testcontainers.MongoDb; + +namespace Elsa.MongoDb.UnitTests; + +public sealed class MongoWorkflowInstanceStoreTenantIsolationTests : IAsyncLifetime +{ + private readonly MongoDbContainer _container = new MongoDbBuilder().WithImage("mongo:7.0.24").Build(); + private readonly TestTenantAccessor _tenantAccessor = new(); + private MongoClient? _client; + private MongoWorkflowInstanceStore _store = null!; + + public async Task InitializeAsync() + { + try + { + await _container.StartAsync(); + + _client = new MongoClient(_container.GetConnectionString()); + var database = _client.GetDatabase($"elsa-instance-tenants-{Guid.NewGuid():N}"); + var collection = database.GetCollection("workflow_instances"); + var mongoDbStore = new MongoDbStore(collection, _tenantAccessor); + _store = new MongoWorkflowInstanceStore(mongoDbStore, Substitute.For>()); + } + catch + { + await DisposeAsync(); + throw; + } + } + + public async Task DisposeAsync() + { + try + { + _client?.Dispose(); + } + finally + { + await _container.DisposeAsync(); + } + } + + [Fact] + public async Task TenantB_CannotMarkOrSummarize_TenantARunningRow() + { + // Arrange + using (var tenantA = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" })) + await _store.AddAsync(Instance("running-a", WorkflowStatus.Running, WorkflowSubStatus.Executing, isExecuting: true)); + + using (var tenantB = _tenantAccessor.PushContext(new Tenant { Id = "tenant-b" })) + await _store.AddAsync(Instance("running-b", WorkflowStatus.Running, WorkflowSubStatus.Executing, isExecuting: true)); + + // Act + bool marked; + Page summaries; + using (var tenantB = _tenantAccessor.PushContext(new Tenant { Id = "tenant-b" })) + { + marked = await _store.TryMarkInterruptedAsync("running-a"); + summaries = await _store.SummarizeManyAsync( + new WorkflowInstanceFilter(), + PageArgs.FromPage(0, 10), + new WorkflowInstanceOrder(x => x.CreatedAt, OrderDirection.Ascending)); + } + + // Assert + Assert.False(marked); + Assert.Equal(["running-b"], summaries.Items.Select(x => x.Id)); + + using var verifyTenantA = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + var stored = await _store.FindAsync(new WorkflowInstanceFilter { Id = "running-a" }); + Assert.NotNull(stored); + Assert.Equal(WorkflowStatus.Running, stored.Status); + Assert.Equal(WorkflowSubStatus.Executing, stored.SubStatus); + Assert.True(stored.IsExecuting); + } + + [Fact] + public async Task SameTenant_CanMarkAndSummarize_OwnRunningRow() + { + // Arrange + using (var tenantA = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" })) + await _store.AddAsync(Instance("running-a", WorkflowStatus.Running, WorkflowSubStatus.Executing, isExecuting: true)); + + // Act + bool marked; + Page summaries; + using (var tenantA = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" })) + { + marked = await _store.TryMarkInterruptedAsync("running-a"); + summaries = await _store.SummarizeManyAsync( + new WorkflowInstanceFilter(), + PageArgs.FromPage(0, 10), + new WorkflowInstanceOrder(x => x.CreatedAt, OrderDirection.Ascending)); + } + + // Assert + Assert.True(marked); + var summary = Assert.Single(summaries.Items); + Assert.Equal("running-a", summary.Id); + Assert.Equal(WorkflowStatus.Running, summary.Status); + Assert.Equal(WorkflowSubStatus.Interrupted, summary.SubStatus); + + using var verifyTenantA = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + var stored = await _store.FindAsync(new WorkflowInstanceFilter { Id = "running-a" }); + Assert.NotNull(stored); + Assert.Equal(WorkflowStatus.Running, stored.Status); + Assert.Equal(WorkflowSubStatus.Interrupted, stored.SubStatus); + Assert.False(stored.IsExecuting); + } + + private static WorkflowInstance Instance(string id, WorkflowStatus status, WorkflowSubStatus subStatus, bool isExecuting) + { + var now = DateTimeOffset.UtcNow; + return new WorkflowInstance + { + Id = id, + DefinitionId = "definition-1", + DefinitionVersionId = "definition-1:1", + Version = 1, + WorkflowState = new WorkflowState + { + Id = id, + DefinitionId = "definition-1", + DefinitionVersionId = "definition-1:1", + Status = status, + SubStatus = subStatus, + IsExecuting = isExecuting, + CreatedAt = now, + UpdatedAt = now + }, + Status = status, + SubStatus = subStatus, + IsExecuting = isExecuting, + CreatedAt = now, + UpdatedAt = now + }; + } + + private sealed class TestTenantAccessor : ITenantAccessor + { + public string TenantId => Tenant?.Id ?? Tenant.DefaultTenantId; + public Tenant? Tenant { get; private set; } + + public IDisposable PushContext(Tenant? tenant) + { + var previousTenant = Tenant; + Tenant = tenant; + return new Restore(() => Tenant = previousTenant); + } + + private sealed class Restore(Action restore) : IDisposable + { + public void Dispose() => restore(); + } + } +} From cbd6490c649a1b2e12bee73b690bacbdfc118e7c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 25 Sep 2026 12:47:53 +0000 Subject: [PATCH 035/169] fix(dapper): default implementation for ISqlDialect.Update(table, fields) Give the new Update(table, fields) member a default interface implementation so third-party dialects that implement ISqlDialect directly keep compiling. SqlDialectBase keeps the same public method and both call a shared helper. Co-authored-by: Sipke Schoorstra --- .../Abstractions/SqlDialectBase.cs | 8 +--- .../Contracts/ISqlDialect.cs | 13 +++++- .../SqlDialectDefaultUpdateTests.cs | 46 +++++++++++++++++++ 3 files changed, 60 insertions(+), 7 deletions(-) create mode 100644 test/modules/persistence/Elsa.Dapper.UnitTests/SqlDialectDefaultUpdateTests.cs diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Abstractions/SqlDialectBase.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Abstractions/SqlDialectBase.cs index 0a7378ee6c..2c130a477e 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Abstractions/SqlDialectBase.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Abstractions/SqlDialectBase.cs @@ -93,12 +93,8 @@ public string Update(string table, string primaryKeyField, string[] fields, Func } /// - public string Update(string table, string[] fields, Func? getParamName = null) - { - getParamName ??= x => x; - var fieldList = string.Join(", ", fields.Select(x => $"{x} = @{getParamName(x)}")); - return $"UPDATE {table} SET {fieldList} WHERE 1=1"; - } + public string Update(string table, string[] fields, Func? getParamName = null) => + SqlDialectStatements.Update(table, fields, getParamName); /// public virtual string Upsert(string table, string primaryKeyField, string[] fields, Func? getParamName = null) diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Contracts/ISqlDialect.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Contracts/ISqlDialect.cs index 6912440ef9..52b5178a9d 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Contracts/ISqlDialect.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Contracts/ISqlDialect.cs @@ -130,7 +130,8 @@ public interface ISqlDialect /// The fields to update. /// An optional function to get the parameter name. /// The query. - string Update(string table, string[] fields, Func? getParamName = null); + string Update(string table, string[] fields, Func? getParamName = null) => + SqlDialectStatements.Update(table, fields, getParamName); /// /// Builds an UPSERT query. @@ -141,4 +142,14 @@ public interface ISqlDialect /// An optional function to get the parameter name. /// The query. string Upsert(string table, string primaryKeyField, string[] fields, Func? getParamName = null); +} + +internal static class SqlDialectStatements +{ + public static string Update(string table, string[] fields, Func? getParamName) + { + getParamName ??= static x => x; + var fieldList = string.Join(", ", fields.Select(x => $"{x} = @{getParamName(x)}")); + return $"UPDATE {table} SET {fieldList} WHERE 1=1"; + } } \ No newline at end of file diff --git a/test/modules/persistence/Elsa.Dapper.UnitTests/SqlDialectDefaultUpdateTests.cs b/test/modules/persistence/Elsa.Dapper.UnitTests/SqlDialectDefaultUpdateTests.cs new file mode 100644 index 0000000000..ddc92d730a --- /dev/null +++ b/test/modules/persistence/Elsa.Dapper.UnitTests/SqlDialectDefaultUpdateTests.cs @@ -0,0 +1,46 @@ +using Elsa.Common.Entities; +using Elsa.Common.Models; +using Elsa.Persistence.Dapper.Contracts; + +namespace Elsa.Dapper.UnitTests; + +public class SqlDialectDefaultUpdateTests +{ + [Fact(DisplayName = "Direct ISqlDialect implementers inherit the default Update(table, fields) SQL")] + public void Update_WithoutCustomImplementation_ReturnsWhereAlwaysTrueSql() + { + // Arrange + var dialect = new StubSqlDialect(); + + // Act + var sql = ((ISqlDialect)dialect).Update("T", ["A", "B"]); + + // Assert + Assert.Equal("UPDATE T SET A = @A, B = @B WHERE 1=1", sql); + } + + /// + /// Implements directly without the new Update(table, fields) overload. + /// + private sealed class StubSqlDialect : ISqlDialect + { + public string From(string table) => throw new NotImplementedException(); + public string From(string table, params string[] fields) => throw new NotImplementedException(); + public string Delete(string table) => throw new NotImplementedException(); + public string Count(string table) => throw new NotImplementedException(); + public string Count(string fieldExpression, string table) => throw new NotImplementedException(); + public string And(string field) => throw new NotImplementedException(); + public string AndNot(string field) => throw new NotImplementedException(); + public string And(string field, string[] fieldParamNames) => throw new NotImplementedException(); + public string AndNot(string field, string[] fieldParamNames) => throw new NotImplementedException(); + public string IsNull(string field) => throw new NotImplementedException(); + public string IsNotNull(string field) => throw new NotImplementedException(); + public string OrderBy(string field, OrderDirection direction) => throw new NotImplementedException(); + public string Skip(int count) => throw new NotImplementedException(); + public string Take(int count) => throw new NotImplementedException(); + public string Page(PageArgs pageArgs) => throw new NotImplementedException(); + public string Insert(string table, string[] fields, Func? getParamName = null) => throw new NotImplementedException(); + public string Update(string table, string primaryKeyField, string[] fields, Func? getParamName = null) => throw new NotImplementedException(); + public string Upsert(string table, string primaryKeyField, string[] fields, Func? getParamName = null) => throw new NotImplementedException(); + } +} From 9102962562b7398df35b7c47ef8f930b54908d10 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 26 Sep 2026 01:00:07 +0000 Subject: [PATCH 036/169] fix(hosts): branding reads "Elsa Studio 3.9" Update the reference Server host app bar and the WASM page title so they no longer show stale 3.8/3.6 branding. Co-authored-by: Sipke Schoorstra --- src/hosts/Elsa.Studio.Host.Server/StudioBrandingProvider.cs | 2 +- src/hosts/Elsa.Studio.Host.Wasm/wwwroot/index.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hosts/Elsa.Studio.Host.Server/StudioBrandingProvider.cs b/src/hosts/Elsa.Studio.Host.Server/StudioBrandingProvider.cs index b7e62d5a65..8e0987b981 100644 --- a/src/hosts/Elsa.Studio.Host.Server/StudioBrandingProvider.cs +++ b/src/hosts/Elsa.Studio.Host.Server/StudioBrandingProvider.cs @@ -5,5 +5,5 @@ namespace Elsa.Studio.Host.Server; /// public class StudioBrandingProvider : DefaultBrandingProvider { - public override string AppNameWithVersion => "Elsa Studio 3.8"; + public override string AppNameWithVersion => "Elsa Studio 3.9"; } \ No newline at end of file diff --git a/src/hosts/Elsa.Studio.Host.Wasm/wwwroot/index.html b/src/hosts/Elsa.Studio.Host.Wasm/wwwroot/index.html index c6bb7fe710..c2b22b1312 100644 --- a/src/hosts/Elsa.Studio.Host.Wasm/wwwroot/index.html +++ b/src/hosts/Elsa.Studio.Host.Wasm/wwwroot/index.html @@ -4,7 +4,7 @@ - Elsa Studio 3.6 + Elsa Studio 3.9 From 1a2b1fd42f6e9783e55852e22ec7bb23ace88b8f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 26 Sep 2026 01:31:30 +0000 Subject: [PATCH 037/169] fix(mongodb): tenant-scope paged definition summaries and TenantAgnostic flags Apply the store tenant read scope to both paged FindSummariesAsync overloads so Studio cannot list or count another tenant's workflow definitions. Pass filter.TenantAgnostic through the Mongo trigger paged query and BookmarkQueueStore methods, and reuse the scoped List/Count path in LabelStore.ListAsync. Co-authored-by: Sipke Schoorstra --- .../Modules/Labels/LabelStore.cs | 6 +- .../Management/WorkflowDefinitionStore.cs | 26 +-- .../Modules/Runtime/BookmarkQueueStore.cs | 10 +- .../Modules/Runtime/TriggerStore.cs | 4 +- .../MongoLabelStoreTenantIsolationTests.cs | 113 +++++++++++++ ...TriggerBookmarkQueueTenantAgnosticTests.cs | 155 ++++++++++++++++++ ...flowDefinitionStoreTenantIsolationTests.cs | 142 ++++++++++++++++ 7 files changed, 435 insertions(+), 21 deletions(-) create mode 100644 test/modules/persistence/Elsa.MongoDb.UnitTests/MongoLabelStoreTenantIsolationTests.cs create mode 100644 test/modules/persistence/Elsa.MongoDb.UnitTests/MongoTriggerBookmarkQueueTenantAgnosticTests.cs create mode 100644 test/modules/persistence/Elsa.MongoDb.UnitTests/MongoWorkflowDefinitionStoreTenantIsolationTests.cs diff --git a/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Labels/LabelStore.cs b/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Labels/LabelStore.cs index e7dd060489..f0de8f4258 100644 --- a/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Labels/LabelStore.cs +++ b/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Labels/LabelStore.cs @@ -4,8 +4,6 @@ using Elsa.Labels.Entities; using Elsa.Persistence.MongoDb.Common; using JetBrains.Annotations; -using MongoDB.Driver; -using MongoDB.Driver.Linq; namespace Elsa.Persistence.MongoDb.Modules.Labels; @@ -44,11 +42,11 @@ public async Task> ListAsync(PageArgs? pageArgs = null, Cancellation { if (pageArgs?.Page is null || pageArgs.PageSize is null) { - var allDocuments = await _labelMongoDbStore.GetCollection().AsQueryable().ToListAsync(cancellationToken); + var allDocuments = (await _labelMongoDbStore.ListAsync(cancellationToken: cancellationToken)).ToList(); return Page.Of(allDocuments, allDocuments.Count); } - var count = await _labelMongoDbStore.GetCollection().AsQueryable().LongCountAsync(cancellationToken); + var count = await _labelMongoDbStore.CountAsync(query => query, cancellationToken); var documents = (await _labelMongoDbStore.FindManyAsync(query => Paginate(query, pageArgs), cancellationToken)).ToList(); return Page.Of(documents, count); } diff --git a/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowDefinitionStore.cs b/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowDefinitionStore.cs index 269fa0b643..415619d519 100644 --- a/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowDefinitionStore.cs +++ b/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowDefinitionStore.cs @@ -63,11 +63,14 @@ public Task> FindManyAsync(WorkflowDef /// public async Task> FindSummariesAsync(WorkflowDefinitionFilter filter, PageArgs pageArgs, CancellationToken cancellationToken = default) { - var collection = mongoDbStore.GetCollection(); - var queryable = Filter(collection.AsQueryable(), filter); - var count = queryable.LongCount(); - queryable = queryable.Paginate(pageArgs)!; - var documents = await queryable.Select(ExpressionHelpers.WorkflowDefinitionSummary).ToListAsync(cancellationToken); + var tenantAgnostic = filter.TenantAgnostic; + var count = await mongoDbStore.CountAsync(queryable => Filter(queryable, filter), tenantAgnostic, cancellationToken); + var documents = await mongoDbStore.FindManyAsync( + queryable => Paginate(Filter(queryable, filter), pageArgs), + ExpressionHelpers.WorkflowDefinitionSummary, + tenantAgnostic, + cancellationToken) + .ToList(); return Page.Of(documents, count); } @@ -75,11 +78,14 @@ public async Task> FindSummariesAsync(WorkflowDe /// public async Task> FindSummariesAsync(WorkflowDefinitionFilter filter, WorkflowDefinitionOrder order, PageArgs pageArgs, CancellationToken cancellationToken = default) { - var collection = mongoDbStore.GetCollection(); - var queryable = Order(Filter(collection.AsQueryable(), filter), order); - var count = queryable.LongCount(); - var mongoQueryable = queryable.Paginate(pageArgs)!; - var documents = await mongoQueryable.Select(ExpressionHelpers.WorkflowDefinitionSummary).ToListAsync(cancellationToken); + var tenantAgnostic = filter.TenantAgnostic; + var count = await mongoDbStore.CountAsync(queryable => Order(Filter(queryable, filter), order), tenantAgnostic, cancellationToken); + var documents = await mongoDbStore.FindManyAsync( + queryable => OrderAndPaginate(Filter(queryable, filter), order, pageArgs), + ExpressionHelpers.WorkflowDefinitionSummary, + tenantAgnostic, + cancellationToken) + .ToList(); return Page.Of(documents, count); } diff --git a/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Runtime/BookmarkQueueStore.cs b/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Runtime/BookmarkQueueStore.cs index 45a19bc15c..1f75cc57e2 100644 --- a/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Runtime/BookmarkQueueStore.cs +++ b/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Runtime/BookmarkQueueStore.cs @@ -31,12 +31,12 @@ public async Task AddAsync(BookmarkQueueItem record, CancellationToken cancellat /// public async Task FindAsync(BookmarkQueueFilter filter, CancellationToken cancellationToken = default) { - return await mongoDbStore.FindAsync(query => Filter(query, filter), cancellationToken); + return await mongoDbStore.FindAsync(query => Filter(query, filter), filter.TenantAgnostic, cancellationToken); } public async Task> FindManyAsync(BookmarkQueueFilter filter, CancellationToken cancellationToken = default) { - return await mongoDbStore.FindManyAsync(query => Filter(query, filter), cancellationToken); + return await mongoDbStore.FindManyAsync(query => Filter(query, filter), filter.TenantAgnostic, cancellationToken); } public async Task> PageAsync(PageArgs pageArgs, BookmarkQueueItemOrder orderBy, CancellationToken cancellationToken = default) @@ -48,15 +48,15 @@ public async Task> PageAsync(PageArgs pageArgs public async Task> PageAsync(PageArgs pageArgs, BookmarkQueueFilter filter, BookmarkQueueItemOrder orderBy, CancellationToken cancellationToken = default) { - var results = await mongoDbStore.FindManyAsync(query => Paginate(Order(Filter(query, filter), orderBy), pageArgs), cancellationToken); - var count = await mongoDbStore.CountAsync(queryable => Filter(queryable, filter), cancellationToken); + var results = await mongoDbStore.FindManyAsync(query => Paginate(Order(Filter(query, filter), orderBy), pageArgs), filter.TenantAgnostic, cancellationToken); + var count = await mongoDbStore.CountAsync(queryable => Filter(queryable, filter), filter.TenantAgnostic, cancellationToken); return Page.Of(results.ToList(), count); } /// public async Task DeleteAsync(BookmarkQueueFilter filter, CancellationToken cancellationToken = default) { - return await mongoDbStore.DeleteWhereAsync(query => Filter(query, filter), x => x.Id, cancellationToken); + return await mongoDbStore.DeleteWhereAsync(query => Filter(query, filter), x => x.Id, filter.TenantAgnostic, cancellationToken); } private IQueryable Filter(IQueryable queryable, BookmarkQueueFilter filter) diff --git a/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Runtime/TriggerStore.cs b/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Runtime/TriggerStore.cs index f9df557cbd..24dda7dc57 100644 --- a/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Runtime/TriggerStore.cs +++ b/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Runtime/TriggerStore.cs @@ -46,8 +46,8 @@ public async ValueTask> FindManyAsync(TriggerFilter filter, public async ValueTask> FindManyAsync(TriggerFilter filter, PageArgs pageArgs, StoredTriggerOrder order, CancellationToken cancellationToken = default) { - var count = await mongoDbStore.CountAsync(queryable => Filter(queryable, filter), cancellationToken); - var results = await mongoDbStore.FindManyAsync(queryable => Paginate(Order(Filter(queryable, filter), order), pageArgs), cancellationToken).ToList(); + var count = await mongoDbStore.CountAsync(queryable => Filter(queryable, filter), filter.TenantAgnostic, cancellationToken); + var results = await mongoDbStore.FindManyAsync(queryable => Paginate(Order(Filter(queryable, filter), order), pageArgs), filter.TenantAgnostic, cancellationToken).ToList(); return new(results, count); } diff --git a/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoLabelStoreTenantIsolationTests.cs b/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoLabelStoreTenantIsolationTests.cs new file mode 100644 index 0000000000..8bb102874f --- /dev/null +++ b/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoLabelStoreTenantIsolationTests.cs @@ -0,0 +1,113 @@ +using Elsa.Common.Models; +using Elsa.Common.Multitenancy; +using Elsa.Labels.Entities; +using Elsa.Persistence.MongoDb.Common; +using Elsa.Persistence.MongoDb.Modules.Labels; +using MongoDB.Driver; +using Testcontainers.MongoDb; + +namespace Elsa.MongoDb.UnitTests; + +public sealed class MongoLabelStoreTenantIsolationTests : IAsyncLifetime +{ + private readonly MongoDbContainer _container = new MongoDbBuilder().WithImage("mongo:7.0.24").Build(); + private readonly TestTenantAccessor _tenantAccessor = new(); + private MongoClient? _client; + private MongoLabelStore _store = null!; + + public async Task InitializeAsync() + { + try + { + await _container.StartAsync(); + + _client = new MongoClient(_container.GetConnectionString()); + var database = _client.GetDatabase($"elsa-label-tenants-{Guid.NewGuid():N}"); + _store = new MongoLabelStore( + new MongoDbStore /// /// For documents that derive from , an ambient tenant ID is assigned only when -/// the document's is . Explicit tenant IDs, -/// including , are preserved. Tenant-scoped reads include -/// agnostic documents, while tenant-scoped deletes exclude them. Pass tenantAgnostic: true -/// (or use an explicit * tenant context) when an operation is intended to manage shared -/// documents. +/// the document's is or empty (the default +/// tenant). Explicit tenant IDs, including , are preserved. +/// Upserts match the key together with that stamped TenantId, and only replace a row owned by the +/// writer or ; a miss against an existing _id surfaces +/// MongoDB's duplicate-key error. Tenant-scoped reads include agnostic documents, while +/// tenant-scoped deletes exclude them. Pass tenantAgnostic: true (or use an explicit +/// * tenant context) when an operation is intended to manage shared documents. /// /// Existing documents with a tenant ID are not migrated automatically. They /// remain in the ambient/default scope; deployments that need them to be shared must migrate those @@ -73,7 +75,7 @@ public async Task AddManyAsync(IEnumerable documents, CancellationTok public async Task SaveAsync(TDocument document, CancellationToken cancellationToken = default) { ApplyTenantId(document); - return await collection.FindOneAndReplaceAsync(document.BuildIdFilter(), document, new FindOneAndReplaceOptions + return await collection.FindOneAndReplaceAsync(CreateTenantOwnedUpsertFilter(document, document.BuildIdFilter()), document, new FindOneAndReplaceOptions { ReturnDocument = ReturnDocument.After, IsUpsert = true @@ -89,7 +91,7 @@ public async Task SaveAsync(TDocument document, CancellationToken can public async Task SaveAsync(TDocument document, Expression> selector, CancellationToken cancellationToken = default) { ApplyTenantId(document); - return await collection.FindOneAndReplaceAsync(document.BuildExpression(selector), document, new FindOneAndReplaceOptions + return await collection.FindOneAndReplaceAsync(CreateTenantOwnedUpsertFilter(document, document.BuildExpression(selector)), document, new FindOneAndReplaceOptions { ReturnDocument = ReturnDocument.After, IsUpsert = true @@ -109,7 +111,7 @@ public async Task SaveManyAsync(IEnumerable documents, CancellationTo foreach (var document in documentsList) { - var replacement = new ReplaceOneModel(document.BuildIdFilter(), document) + var replacement = new ReplaceOneModel(CreateTenantOwnedUpsertFilter(document, document.BuildIdFilter()), document) { IsUpsert = true }; @@ -136,7 +138,7 @@ public async Task SaveManyAsync(IEnumerable documents, string primary foreach (var document in documentsList) { - var replacement = new ReplaceOneModel(document.BuildFilter(primaryKey), document) + var replacement = new ReplaceOneModel(CreateTenantOwnedUpsertFilter(document, document.BuildFilter(primaryKey)), document) { IsUpsert = true }; @@ -483,23 +485,42 @@ private IQueryable GetQueryableCollection(bool tenantAgnostic = false private void ApplyTenantId(TDocument document) { - var tenantId = GetTenantId(); + if (document is not Entity tenantDocument) + return; - // Preserve explicit tenant IDs, including tenant-agnostic ("*") entities. Only stamp - // entities that have not been assigned an owner yet. - if (document is Entity { TenantId: null } tenantDocument) - tenantDocument.TenantId = tenantId; + // RoleManager/UserManager stamp "" for the default tenant. Treat empty like GetTenantId + // (EmptyToNull) so a default-tenant re-save matches the stored null owner. + tenantDocument.TenantId = tenantDocument.TenantId.EmptyToNull() ?? GetTenantId(); } private void ApplyTenantId(IEnumerable documents) { - var tenantId = GetTenantId(); - foreach (var document in documents) - { - if (document is Entity { TenantId: null } tenantDocument) - tenantDocument.TenantId = tenantId; - } + ApplyTenantId(document); + } + + /// + /// Upsert filter: key/Id AND TenantId equals the document's TenantId (after stamping) + /// AND that TenantId is the writer's tenant or . + /// The writer is for the default tenant, matching read + /// visibility. Equality on TenantId means a write can never change a row's owner, so a + /// named tenant cannot match null-owned rows. This is not the strict write scope; a + /// named tenant may still replace a * row when the incoming document keeps + /// TenantId as * (the populator). + /// + private FilterDefinition CreateTenantOwnedUpsertFilter(TDocument document, Expression> keyFilter) + { + var filter = Builders.Filter.Where(keyFilter); + + if (document is not Entity entity) + return filter; + + var documentTenantId = entity.TenantId; + var writerTenantId = GetTenantId(); + + return filter + & Builders.Filter.Where(x => (x as Entity)!.TenantId == documentTenantId) + & Builders.Filter.Where(x => (x as Entity)!.TenantId == writerTenantId || (x as Entity)!.TenantId == Tenant.AgnosticTenantId); } /// diff --git a/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoTenantOwnedUpsertTests.cs b/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoTenantOwnedUpsertTests.cs new file mode 100644 index 0000000000..4b75aa0bcf --- /dev/null +++ b/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoTenantOwnedUpsertTests.cs @@ -0,0 +1,398 @@ +using Elsa.Common.Entities; +using Elsa.Common.Multitenancy; +using Elsa.Identity.Entities; +using Elsa.KeyValues.Entities; +using Elsa.Persistence.MongoDb.Common; +using Elsa.Workflows; +using Elsa.Workflows.Management.Entities; +using Elsa.Workflows.State; +using MongoDB.Driver; +using Testcontainers.MongoDb; + +namespace Elsa.MongoDb.UnitTests; + +public sealed class MongoTenantOwnedUpsertTests : IClassFixture +{ + private readonly MongoClient _client; + private readonly TestTenantAccessor _tenantAccessor = new(); + private readonly MongoDbStore _instances; + private readonly MongoDbStore _definitions; + private readonly MongoDbStore _keyValues; + private readonly MongoDbStore _roles; + private readonly MongoDbStore _users; + + public MongoTenantOwnedUpsertTests(MongoFixture fixture) + { + _client = new MongoClient(fixture.Container.GetConnectionString()); + var database = _client.GetDatabase($"elsa-owned-upsert-{Guid.NewGuid():N}"); + + _instances = new MongoDbStore(database.GetCollection("workflow_instances"), _tenantAccessor); + _definitions = new MongoDbStore(database.GetCollection("workflow_definitions"), _tenantAccessor); + _keyValues = new MongoDbStore(database.GetCollection("key_values"), _tenantAccessor); + _roles = new MongoDbStore(database.GetCollection("roles"), _tenantAccessor); + _users = new MongoDbStore(database.GetCollection("users"), _tenantAccessor); + } + + [Fact] + public async Task TenantB_SaveAsync_WithTenantAInstanceId_IsRefused_AndARowUnchanged() + { + // Arrange + using (var tenantA = _tenantAccessor.PushContext(TenantA())) + await _instances.SaveAsync(Instance("instance-a", "owned-by-a")); + + // Act + Exception? exception; + using (var tenantB = _tenantAccessor.PushContext(TenantB())) + exception = await Record.ExceptionAsync(() => _instances.SaveAsync(Instance("instance-a", "taken-by-b"))); + + // Assert + AssertDuplicateKey(exception); + await AssertInstanceUnchanged("instance-a", "tenant-a", "owned-by-a"); + } + + [Fact] + public async Task TenantB_SaveManyAsync_WithTenantAInstanceId_IsRefused_AndARowUnchanged() + { + // Arrange + using (var tenantA = _tenantAccessor.PushContext(TenantA())) + await _instances.SaveAsync(Instance("instance-many-a", "owned-by-a")); + + // Act + Exception? exception; + using (var tenantB = _tenantAccessor.PushContext(TenantB())) + exception = await Record.ExceptionAsync(() => _instances.SaveManyAsync([Instance("instance-many-a", "taken-by-b")])); + + // Assert + AssertDuplicateKey(exception); + await AssertInstanceUnchanged("instance-many-a", "tenant-a", "owned-by-a"); + } + + [Fact] + public async Task TenantB_SaveAsync_WithTenantADefinitionId_IsRefused_AndARowUnchanged() + { + // Arrange + using (var tenantA = _tenantAccessor.PushContext(TenantA())) + await _definitions.SaveAsync(Definition("definition-a", "owned-by-a")); + + // Act + Exception? exception; + using (var tenantB = _tenantAccessor.PushContext(TenantB())) + exception = await Record.ExceptionAsync(() => _definitions.SaveAsync(Definition("definition-a", "taken-by-b"))); + + // Assert + AssertDuplicateKey(exception); + await AssertDefinitionUnchanged("definition-a", "tenant-a", "owned-by-a"); + } + + [Fact] + public async Task TenantB_SaveManyAsync_WithTenantADefinitionId_IsRefused_AndARowUnchanged() + { + // Arrange + using (var tenantA = _tenantAccessor.PushContext(TenantA())) + await _definitions.SaveAsync(Definition("definition-many-a", "owned-by-a")); + + // Act + Exception? exception; + using (var tenantB = _tenantAccessor.PushContext(TenantB())) + exception = await Record.ExceptionAsync(() => _definitions.SaveManyAsync([Definition("definition-many-a", "taken-by-b")])); + + // Assert + AssertDuplicateKey(exception); + await AssertDefinitionUnchanged("definition-many-a", "tenant-a", "owned-by-a"); + } + + [Fact] + public async Task TenantB_SaveAsync_WithTenantAKey_IsRefused_AndARowUnchanged() + { + // Arrange + using (var tenantA = _tenantAccessor.PushContext(TenantA())) + await _keyValues.SaveAsync(KeyValue("shared-key", "owned-by-a"), x => x.Key); + + // Act + Exception? exception; + using (var tenantB = _tenantAccessor.PushContext(TenantB())) + exception = await Record.ExceptionAsync(() => _keyValues.SaveAsync(KeyValue("shared-key", "taken-by-b"), x => x.Key)); + + // Assert + AssertDuplicateKey(exception); + await AssertKeyValueUnchanged("shared-key", "tenant-a", "owned-by-a"); + } + + [Fact] + public async Task TenantB_SaveManyAsync_WithTenantAKey_IsRefused_AndARowUnchanged() + { + // Arrange + using (var tenantA = _tenantAccessor.PushContext(TenantA())) + await _keyValues.SaveAsync(KeyValue("shared-key-many", "owned-by-a"), x => x.Key); + + // Act + Exception? exception; + using (var tenantB = _tenantAccessor.PushContext(TenantB())) + exception = await Record.ExceptionAsync(() => _keyValues.SaveManyAsync([KeyValue("shared-key-many", "taken-by-b")], nameof(SerializedKeyValuePair.Key))); + + // Assert + AssertDuplicateKey(exception); + await AssertKeyValueUnchanged("shared-key-many", "tenant-a", "owned-by-a"); + } + + [Fact] + public async Task TenantB_CreatingAdminRole_LeavesTenantAAdminIntact() + { + // Arrange + using (var tenantA = _tenantAccessor.PushContext(TenantA())) + await _roles.SaveAsync(Role("admin", "Admin", "perm-a")); + + // Act + Exception? exception; + using (var tenantB = _tenantAccessor.PushContext(TenantB())) + exception = await Record.ExceptionAsync(() => _roles.SaveAsync(Role("admin", "Admin", "perm-b"))); + + // Assert + AssertDuplicateKey(exception); + await AssertRoleUnchanged("admin", "tenant-a", "perm-a"); + } + + [Fact] + public async Task TenantB_SaveManyAsync_CreatingAdminRole_LeavesTenantAAdminIntact() + { + // Arrange + using (var tenantA = _tenantAccessor.PushContext(TenantA())) + await _roles.SaveAsync(Role("admin-many", "Admin", "perm-a")); + + // Act + Exception? exception; + using (var tenantB = _tenantAccessor.PushContext(TenantB())) + exception = await Record.ExceptionAsync(() => _roles.SaveManyAsync([Role("admin-many", "Admin", "perm-b")])); + + // Assert + AssertDuplicateKey(exception); + await AssertRoleUnchanged("admin-many", "tenant-a", "perm-a"); + } + + [Fact] + public async Task SameTenant_SaveAsync_UpdatesOwnRow() + { + // Arrange + using (var tenantA = _tenantAccessor.PushContext(TenantA())) + await _definitions.SaveAsync(Definition("definition-own", "original")); + + // Act + using (var tenantA = _tenantAccessor.PushContext(TenantA())) + await _definitions.SaveAsync(Definition("definition-own", "updated")); + + // Assert + await AssertDefinitionUnchanged("definition-own", "tenant-a", "updated"); + } + + [Fact] + public async Task NamedTenant_PopulatorStyleAgnosticResave_KeepsStarTenantId() + { + // Arrange + using (var tenantA = _tenantAccessor.PushContext(TenantA())) + await _definitions.SaveAsync(Definition("clr-definition", "code-defined", Tenant.AgnosticTenantId)); + + // Act + using (var tenantA = _tenantAccessor.PushContext(TenantA())) + await _definitions.SaveAsync(Definition("clr-definition", "reloaded", Tenant.AgnosticTenantId)); + + // Assert + await AssertDefinitionUnchanged("clr-definition", Tenant.AgnosticTenantId, "reloaded"); + } + + [Fact] + public async Task NamedTenant_SaveWithNullTenantId_OverAgnosticId_IsRefused() + { + // Arrange + using (var tenantA = _tenantAccessor.PushContext(TenantA())) + await _definitions.SaveAsync(Definition("star-definition", "code-defined", Tenant.AgnosticTenantId)); + + // Act + Exception? exception; + using (var tenantB = _tenantAccessor.PushContext(TenantB())) + exception = await Record.ExceptionAsync(() => _definitions.SaveAsync(Definition("star-definition", "taken-by-b"))); + + // Assert + AssertDuplicateKey(exception); + await AssertDefinitionUnchanged("star-definition", Tenant.AgnosticTenantId, "code-defined"); + } + + [Fact] + public async Task DefaultTenant_CanUpdateNullRows_AndNamedTenantCannot() + { + // Arrange + using (var defaultTenant = _tenantAccessor.PushContext(null)) + await _definitions.SaveAsync(Definition("legacy-definition", "null-owned")); + + // Act + using (var defaultTenant = _tenantAccessor.PushContext(null)) + await _definitions.SaveAsync(Definition("legacy-definition", "updated-by-default")); + + Exception? namedTakeover; + using (var tenantB = _tenantAccessor.PushContext(TenantB())) + namedTakeover = await Record.ExceptionAsync(() => _definitions.SaveAsync(Definition("legacy-definition", "taken-by-b"))); + + // Assert + await AssertDefinitionUnchanged("legacy-definition", null, "updated-by-default"); + AssertDuplicateKey(namedTakeover); + } + + [Fact] + public async Task ApplyTenantId_NormalisesEmptyStringToNull_DefaultTenantRoleAndUserResaveWorks() + { + // Arrange + using (var defaultTenant = _tenantAccessor.PushContext(null)) + { + await _roles.SaveAsync(Role("admin", "Admin", "perm-default")); + await _users.SaveAsync(User("user-1", "alice")); + } + + // Act + using (var defaultTenant = _tenantAccessor.PushContext(null)) + { + await _roles.SaveAsync(Role("admin", "Admin", "perm-resave", Tenant.DefaultTenantId)); + await _users.SaveAsync(User("user-1", "alice-updated", Tenant.DefaultTenantId)); + } + + // Assert + var role = await FindById(_roles, "admin"); + Assert.NotNull(role); + Assert.Null(role.TenantId); + Assert.Equal(["perm-resave"], role.Permissions); + + var user = await FindById(_users, "user-1"); + Assert.NotNull(user); + Assert.Null(user.TenantId); + Assert.Equal("alice-updated", user.Name); + } + + private async Task AssertInstanceUnchanged(string id, string? tenantId, string correlationId) + { + var stored = await FindById(_instances, id); + Assert.NotNull(stored); + Assert.Equal(tenantId, stored.TenantId); + Assert.Equal(correlationId, stored.CorrelationId); + } + + private async Task AssertDefinitionUnchanged(string id, string? tenantId, string name) + { + var stored = await FindById(_definitions, id); + Assert.NotNull(stored); + Assert.Equal(tenantId, stored.TenantId); + Assert.Equal(name, stored.Name); + } + + private async Task AssertKeyValueUnchanged(string key, string? tenantId, string serializedValue) + { + var stored = await FindById(_keyValues, key); + Assert.NotNull(stored); + Assert.Equal(tenantId, stored.TenantId); + Assert.Equal(serializedValue, stored.SerializedValue); + } + + private async Task AssertRoleUnchanged(string id, string? tenantId, string permission) + { + var stored = await FindById(_roles, id); + Assert.NotNull(stored); + Assert.Equal(tenantId, stored.TenantId); + Assert.Equal([permission], stored.Permissions); + } + + private async Task FindById(MongoDbStore store, string id) + where TDocument : class + { + var documents = await store.ListAsync(tenantAgnostic: true); + return documents.OfType().SingleOrDefault(x => x.Id == id) as TDocument; + } + + private static void AssertDuplicateKey(Exception? exception) + { + Assert.NotNull(exception); + Assert.Contains("E11000", exception.ToString(), StringComparison.Ordinal); + } + + private static Tenant TenantA() => new() { Id = "tenant-a" }; + private static Tenant TenantB() => new() { Id = "tenant-b" }; + + private static WorkflowDefinition Definition(string id, string name, string? tenantId = null) => new() + { + Id = id, + DefinitionId = id, + Name = name, + TenantId = tenantId + }; + + private static SerializedKeyValuePair KeyValue(string key, string value) => new() + { + Key = key, + SerializedValue = value + }; + + private static Role Role(string id, string name, string permission, string? tenantId = null) => new() + { + Id = id, + Name = name, + TenantId = tenantId, + Permissions = [permission] + }; + + private static User User(string id, string name, string? tenantId = null) => new() + { + Id = id, + Name = name, + TenantId = tenantId + }; + + private static WorkflowInstance Instance(string id, string correlationId) + { + var now = DateTimeOffset.UtcNow; + return new WorkflowInstance + { + Id = id, + DefinitionId = "definition-1", + DefinitionVersionId = "definition-1:1", + Version = 1, + CorrelationId = correlationId, + WorkflowState = new WorkflowState + { + Id = id, + DefinitionId = "definition-1", + DefinitionVersionId = "definition-1:1", + Status = WorkflowStatus.Running, + SubStatus = WorkflowSubStatus.Executing, + CreatedAt = now, + UpdatedAt = now + }, + Status = WorkflowStatus.Running, + SubStatus = WorkflowSubStatus.Executing, + CreatedAt = now, + UpdatedAt = now + }; + } + + public sealed class MongoFixture : IAsyncLifetime + { + public MongoDbContainer Container { get; } = new MongoDbBuilder().WithImage("mongo:7.0.24").Build(); + + public Task InitializeAsync() => Container.StartAsync(); + + public Task DisposeAsync() => Container.DisposeAsync().AsTask(); + } + + private sealed class TestTenantAccessor : ITenantAccessor + { + public string TenantId => Tenant?.Id ?? Tenant.DefaultTenantId; + public Tenant? Tenant { get; private set; } + + public IDisposable PushContext(Tenant? tenant) + { + var previousTenant = Tenant; + Tenant = tenant; + return new Restore(() => Tenant = previousTenant); + } + + private sealed class Restore(Action restore) : IDisposable + { + public void Dispose() => restore(); + } + } +} From 82e240b869f6f3c242818605722a423f99307e38 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 26 Sep 2026 01:40:23 +0000 Subject: [PATCH 039/169] test(mongodb): disambiguate SaveManyAsync overloads in tenant-owned upsert tests Co-authored-by: Sipke Schoorstra --- .../Elsa.MongoDb.UnitTests/MongoTenantOwnedUpsertTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoTenantOwnedUpsertTests.cs b/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoTenantOwnedUpsertTests.cs index 4b75aa0bcf..4b2a446972 100644 --- a/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoTenantOwnedUpsertTests.cs +++ b/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoTenantOwnedUpsertTests.cs @@ -60,7 +60,7 @@ public async Task TenantB_SaveManyAsync_WithTenantAInstanceId_IsRefused_AndARowU // Act Exception? exception; using (var tenantB = _tenantAccessor.PushContext(TenantB())) - exception = await Record.ExceptionAsync(() => _instances.SaveManyAsync([Instance("instance-many-a", "taken-by-b")])); + exception = await Record.ExceptionAsync(() => _instances.SaveManyAsync([Instance("instance-many-a", "taken-by-b")], cancellationToken: default)); // Assert AssertDuplicateKey(exception); @@ -94,7 +94,7 @@ public async Task TenantB_SaveManyAsync_WithTenantADefinitionId_IsRefused_AndARo // Act Exception? exception; using (var tenantB = _tenantAccessor.PushContext(TenantB())) - exception = await Record.ExceptionAsync(() => _definitions.SaveManyAsync([Definition("definition-many-a", "taken-by-b")])); + exception = await Record.ExceptionAsync(() => _definitions.SaveManyAsync([Definition("definition-many-a", "taken-by-b")], cancellationToken: default)); // Assert AssertDuplicateKey(exception); @@ -162,7 +162,7 @@ public async Task TenantB_SaveManyAsync_CreatingAdminRole_LeavesTenantAAdminInta // Act Exception? exception; using (var tenantB = _tenantAccessor.PushContext(TenantB())) - exception = await Record.ExceptionAsync(() => _roles.SaveManyAsync([Role("admin-many", "Admin", "perm-b")])); + exception = await Record.ExceptionAsync(() => _roles.SaveManyAsync([Role("admin-many", "Admin", "perm-b")], cancellationToken: default)); // Assert AssertDuplicateKey(exception); From 48a0f63e0d80a29a2c48d62dc89d0abb16ee1884 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 26 Sep 2026 02:35:23 +0000 Subject: [PATCH 040/169] test(mongodb): pin forged-owner upsert refusal and assert DuplicateKey category Add SaveAsync/SaveManyAsync cases where tenant B sends TenantId=tenant-a with A's Id. Those are the only path that needs the writer/* clause. Assert Mongo ServerErrorCategory.DuplicateKey / code 11000 instead of the E11000 string. Co-authored-by: Sipke Schoorstra --- .../MongoTenantOwnedUpsertTests.cs | 45 ++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoTenantOwnedUpsertTests.cs b/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoTenantOwnedUpsertTests.cs index 4b2a446972..1402664624 100644 --- a/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoTenantOwnedUpsertTests.cs +++ b/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoTenantOwnedUpsertTests.cs @@ -169,6 +169,40 @@ public async Task TenantB_SaveManyAsync_CreatingAdminRole_LeavesTenantAAdminInta await AssertRoleUnchanged("admin-many", "tenant-a", "perm-a"); } + [Fact] + public async Task TenantB_SaveAsync_WithForgedTenantAOwner_IsRefused_AndARowUnchanged() + { + // Arrange + using (var tenantA = _tenantAccessor.PushContext(TenantA())) + await _definitions.SaveAsync(Definition("forged-a", "owned-by-a")); + + // Act + Exception? exception; + using (var tenantB = _tenantAccessor.PushContext(TenantB())) + exception = await Record.ExceptionAsync(() => _definitions.SaveAsync(Definition("forged-a", "taken-by-b", "tenant-a"))); + + // Assert + AssertDuplicateKey(exception); + await AssertDefinitionUnchanged("forged-a", "tenant-a", "owned-by-a"); + } + + [Fact] + public async Task TenantB_SaveManyAsync_WithForgedTenantAOwner_IsRefused_AndARowUnchanged() + { + // Arrange + using (var tenantA = _tenantAccessor.PushContext(TenantA())) + await _definitions.SaveAsync(Definition("forged-many-a", "owned-by-a")); + + // Act + Exception? exception; + using (var tenantB = _tenantAccessor.PushContext(TenantB())) + exception = await Record.ExceptionAsync(() => _definitions.SaveManyAsync([Definition("forged-many-a", "taken-by-b", "tenant-a")], cancellationToken: default)); + + // Assert + AssertDuplicateKey(exception); + await AssertDefinitionUnchanged("forged-many-a", "tenant-a", "owned-by-a"); + } + [Fact] public async Task SameTenant_SaveAsync_UpdatesOwnRow() { @@ -307,9 +341,18 @@ private async Task AssertRoleUnchanged(string id, string? tenantId, string permi private static void AssertDuplicateKey(Exception? exception) { Assert.NotNull(exception); - Assert.Contains("E11000", exception.ToString(), StringComparison.Ordinal); + Assert.True(IsDuplicateKey(exception), exception.ToString()); } + private static bool IsDuplicateKey(Exception exception) => + exception switch + { + MongoWriteException write => write.WriteError.Category == ServerErrorCategory.DuplicateKey || write.WriteError.Code == 11000, + MongoCommandException command => command.Code == 11000, + MongoBulkWriteException bulk => bulk.WriteErrors.Any(error => error.Category == ServerErrorCategory.DuplicateKey || error.Code == 11000), + _ => exception.InnerException is { } inner && IsDuplicateKey(inner) + }; + private static Tenant TenantA() => new() { Id = "tenant-a" }; private static Tenant TenantB() => new() { Id = "tenant-b" }; From 6ee02b142aaeb274d9678f698eae1a34832f1ff6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 28 Sep 2026 01:36:43 +0000 Subject: [PATCH 041/169] fix(hosts): derive branding from ToolVersion 3.9 Bump ToolVersion so the WASM app bar and About dialog match 3.9, and drop the Server host override so DefaultBrandingProvider is the source of AppNameWithVersion. Co-authored-by: Sipke Schoorstra --- src/framework/Elsa.Studio.Core/ToolVersion.cs | 2 +- src/hosts/Elsa.Studio.Host.Server/Program.cs | 7 +------ .../Elsa.Studio.Host.Server/StudioBrandingProvider.cs | 9 --------- src/hosts/Elsa.Studio.Host.Wasm/wwwroot/index.html | 1 + 4 files changed, 3 insertions(+), 16 deletions(-) delete mode 100644 src/hosts/Elsa.Studio.Host.Server/StudioBrandingProvider.cs diff --git a/src/framework/Elsa.Studio.Core/ToolVersion.cs b/src/framework/Elsa.Studio.Core/ToolVersion.cs index c3f4e70c83..265a17df6f 100644 --- a/src/framework/Elsa.Studio.Core/ToolVersion.cs +++ b/src/framework/Elsa.Studio.Core/ToolVersion.cs @@ -8,7 +8,7 @@ public static class ToolVersion /// /// The version of the tool. /// - public static readonly Version Version = new(3, 8, 0, 0); + public static readonly Version Version = new(3, 9, 0, 0); /// /// Gets the display version of the tool. diff --git a/src/hosts/Elsa.Studio.Host.Server/Program.cs b/src/hosts/Elsa.Studio.Host.Server/Program.cs index 6754052ee3..20a54523b9 100644 --- a/src/hosts/Elsa.Studio.Host.Server/Program.cs +++ b/src/hosts/Elsa.Studio.Host.Server/Program.cs @@ -6,14 +6,12 @@ using Elsa.Studio.Authentication.Themes.Extensions; using Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Extensions; using Elsa.Studio.Authentication.OpenIdConnect.HttpMessageHandlers; -using Elsa.Studio.Branding; using Elsa.Studio.Contracts; using Elsa.Studio.Core.BlazorServer.Extensions; using Elsa.Studio.AI.Extensions; using Elsa.Studio.Alterations.Extensions; using Elsa.Studio.Dashboard.Extensions; using Elsa.Studio.Extensions; -using Elsa.Studio.Host.Server; using Elsa.Studio.Localization.BlazorServer.Extensions; using Elsa.Studio.Localization.Models; using Elsa.Studio.Localization.Options; @@ -43,7 +41,6 @@ using Elsa.Studio.ExternalAuthentication.BlazorServer.HttpMessageHandlers; using Elsa.Studio.ExternalAuthentication.Extensions; using Elsa.Studio.Authentication.Abstractions.Models; -using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; // Build the host. @@ -143,10 +140,8 @@ } }; -builder.Services.AddScoped(); builder.Services - .AddCore(options => configuration.GetSection(StudioThemeOptions.SectionName).Bind(options)) - .Replace(new(typeof(IBrandingProvider), typeof(StudioBrandingProvider), ServiceLifetime.Scoped)); + .AddCore(options => configuration.GetSection(StudioThemeOptions.SectionName).Bind(options)); builder.Services.AddShell(options => configuration.GetSection("Shell").Bind(options)); if (selectedAuthProvider != StudioAuthenticationProvider.ElsaLogin) { diff --git a/src/hosts/Elsa.Studio.Host.Server/StudioBrandingProvider.cs b/src/hosts/Elsa.Studio.Host.Server/StudioBrandingProvider.cs deleted file mode 100644 index 8e0987b981..0000000000 --- a/src/hosts/Elsa.Studio.Host.Server/StudioBrandingProvider.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Elsa.Studio.Branding; - -namespace Elsa.Studio.Host.Server; - -/// -public class StudioBrandingProvider : DefaultBrandingProvider -{ - public override string AppNameWithVersion => "Elsa Studio 3.9"; -} \ No newline at end of file diff --git a/src/hosts/Elsa.Studio.Host.Wasm/wwwroot/index.html b/src/hosts/Elsa.Studio.Host.Wasm/wwwroot/index.html index c2b22b1312..fc91719c8a 100644 --- a/src/hosts/Elsa.Studio.Host.Wasm/wwwroot/index.html +++ b/src/hosts/Elsa.Studio.Host.Wasm/wwwroot/index.html @@ -4,6 +4,7 @@ + Elsa Studio 3.9 From 10db39486cd48bf8e9aba4e9cf7e4c4f33558c03 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 28 Sep 2026 01:51:44 +0000 Subject: [PATCH 042/169] fix(dapper): skip duplicate AggregateFaultCount in V3_7 runtime migration V3_5 (20006) already adds ActivityExecutionRecords.AggregateFaultCount. V3_7 (20007) added it again, so a fresh SQLite MigrateUp failed and rolled back, leaving SerializedMetadata (and later Identity tables) missing. Co-authored-by: Sipke Schoorstra --- .../Runtime/V3_7.cs | 10 +- .../DapperRuntimeMigrationTests.cs | 174 ++++++++++++++++++ .../Elsa.Persistence.Dapper.UnitTests.csproj | 1 + 3 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperRuntimeMigrationTests.cs diff --git a/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/V3_7.cs b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/V3_7.cs index 1c990c92c7..d3728b7443 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/V3_7.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/V3_7.cs @@ -19,7 +19,11 @@ public override void Up() Alter.Table("ActivityExecutionRecords").AddColumn("SchedulingActivityId").AsString().Nullable(); Alter.Table("ActivityExecutionRecords").AddColumn("SchedulingWorkflowInstanceId").AsString().Nullable(); Alter.Table("ActivityExecutionRecords").AddColumn("CallStackDepth").AsInt32().Nullable(); - Alter.Table("ActivityExecutionRecords").AddColumn("AggregateFaultCount").AsInt32().NotNullable().WithDefaultValue(0); + + // V3_5 (20006) already adds this column. Skip when it is present so a fresh + // migrate does not fail, and so DBs that applied only V3_5 still upgrade. + if (!Schema.Table("ActivityExecutionRecords").Column("AggregateFaultCount").Exists()) + Alter.Table("ActivityExecutionRecords").AddColumn("AggregateFaultCount").AsInt32().NotNullable().WithDefaultValue(0); } /// @@ -30,6 +34,8 @@ public override void Down() Delete.Column("SchedulingActivityId").FromTable("ActivityExecutionRecords"); Delete.Column("SchedulingWorkflowInstanceId").FromTable("ActivityExecutionRecords"); Delete.Column("CallStackDepth").FromTable("ActivityExecutionRecords"); - Delete.Column("AggregateFaultCount").FromTable("ActivityExecutionRecords"); + + // V3_5 (20006) owns AggregateFaultCount. Do not drop it here: rolling back + // V3_7 must leave the column in place, and must not double-drop with V3_5.Down(). } } diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperRuntimeMigrationTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperRuntimeMigrationTests.cs new file mode 100644 index 0000000000..34bbf45381 --- /dev/null +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperRuntimeMigrationTests.cs @@ -0,0 +1,174 @@ +using Dapper; +using Elsa.Common.Multitenancy; +using Elsa.Persistence.Dapper.Extensions; +using Elsa.Persistence.Dapper.Modules.Runtime.Records; +using Elsa.Persistence.Dapper.Services; +using FluentMigrator.Runner; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Persistence.Dapper.UnitTests; + +/// +/// Runs the Dapper FluentMigrator assembly against a fresh SQLite file. +/// Covers the V3_5 / V3_7 AggregateFaultCount overlap from issue #250. +/// +public sealed class DapperRuntimeMigrationTests : IDisposable +{ + private readonly string _databasePath = Path.Combine(Path.GetTempPath(), $"elsa-dapper-runtime-migrations-{Guid.NewGuid():N}.db"); + private readonly string _connectionString; + + public DapperRuntimeMigrationTests() + { + _connectionString = new SqliteConnectionStringBuilder { DataSource = _databasePath, Pooling = false }.ToString(); + } + + [Fact(DisplayName = "A fresh SQLite DB applies every runtime migration and can persist SerializedMetadata + AggregateFaultCount")] + public async Task FreshSqliteDatabase_MigratesAndPersistsActivityExecutionRecord() + { + // Arrange + var store = CreateActivityExecutionStore(); + var record = new ActivityExecutionRecordRecord + { + Id = "rec-1", + WorkflowInstanceId = "wf-1", + ActivityId = "act-1", + ActivityNodeId = "node-1", + ActivityType = "Elsa.WriteLine", + ActivityTypeVersion = 1, + ActivityName = "WriteLine", + StartedAt = DateTimeOffset.UtcNow, + HasBookmarks = false, + Status = "Finished", + SerializedMetadata = """{"source":"fresh-db"}""", + AggregateFaultCount = 2 + }; + + // Act + MigrateUp(); + await store.SaveAsync(record); + var loaded = await store.FindAsync(q => q.Is(nameof(ActivityExecutionRecordRecord.Id), record.Id), cancellationToken: CancellationToken.None); + + // Assert + var columns = ActivityExecutionRecordColumns(); + Assert.Contains("AggregateFaultCount", columns); + Assert.Contains("SerializedMetadata", columns); + Assert.True(TableExists("Roles")); + Assert.NotNull(loaded); + Assert.Equal(2, loaded.AggregateFaultCount); + Assert.Equal("""{"source":"fresh-db"}""", loaded.SerializedMetadata); + } + + [Fact(DisplayName = "A DB stopped at V3_5 (20006) upgrades to latest without a duplicate-column error")] + public void UpgradeFromV3_5_AppliesV3_7WithoutDuplicateAggregateFaultCount() + { + // Arrange + MigrateUp(20006); + var columnsAfterV35 = ActivityExecutionRecordColumns(); + Assert.Contains("AggregateFaultCount", columnsAfterV35); + Assert.DoesNotContain("SerializedMetadata", columnsAfterV35); + + // Act + MigrateUp(); + + // Assert + var columns = ActivityExecutionRecordColumns(); + Assert.Contains("AggregateFaultCount", columns); + Assert.Contains("SerializedMetadata", columns); + Assert.Contains("SchedulingActivityExecutionId", columns); + Assert.Contains("CallStackDepth", columns); + Assert.True(TableExists("Roles")); + } + + [Fact(DisplayName = "Rolling back V3_7 leaves V3_5's AggregateFaultCount so V3_5.Down() does not double-drop")] + public void RollingBackThroughV3_7AndV3_5_DoesNotDoubleDropAggregateFaultCount() + { + // Arrange + MigrateUp(); + + // Act + MigrateDown(20006); + + // Assert: V3_7 rolled back, V3_5 still owns the column + var afterV37Down = ActivityExecutionRecordColumns(); + Assert.Contains("AggregateFaultCount", afterV37Down); + Assert.DoesNotContain("SerializedMetadata", afterV37Down); + + // Act: V3_5.Down() must be able to drop its own column + MigrateDown(20005); + + // Assert + Assert.DoesNotContain("AggregateFaultCount", ActivityExecutionRecordColumns()); + } + + public void Dispose() + { + File.Delete(_databasePath); + } + + private Store CreateActivityExecutionStore() + { + var connectionProvider = new SqliteDbConnectionProvider(_connectionString); + return new Store(connectionProvider, new TestTenantAccessor(), "ActivityExecutionRecords"); + } + + private void MigrateUp(long? targetVersion = null) + { + using var services = CreateMigrator(); + using var scope = services.CreateScope(); + var runner = scope.ServiceProvider.GetRequiredService(); + if (targetVersion is { } version) + runner.MigrateUp(version); + else + runner.MigrateUp(); + } + + private void MigrateDown(long targetVersion) + { + using var services = CreateMigrator(); + using var scope = services.CreateScope(); + var runner = scope.ServiceProvider.GetRequiredService(); + runner.MigrateDown(targetVersion); + } + + private ServiceProvider CreateMigrator() => + new ServiceCollection() + .AddFluentMigratorCore() + .ConfigureRunner(rb => rb + .AddSQLite() + .WithGlobalConnectionString(_connectionString) + .ScanIn(typeof(Elsa.Persistence.Dapper.Migrations.Runtime.Initial).Assembly).For.Migrations()) + .BuildServiceProvider(false); + + private IReadOnlyCollection ActivityExecutionRecordColumns() + { + using var connection = new SqliteConnection(_connectionString); + return connection.Query("SELECT name FROM pragma_table_info('ActivityExecutionRecords')").ToList(); + } + + private bool TableExists(string tableName) + { + using var connection = new SqliteConnection(_connectionString); + return connection.ExecuteScalar( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = @tableName", + new { tableName }) == 1; + } + + private sealed class TestTenantAccessor : ITenantAccessor + { + public string TenantId => Tenant?.Id ?? Tenant.DefaultTenantId; + public Tenant? Tenant { get; private set; } + + public IDisposable PushContext(Tenant? tenant) + { + var previousTenant = Tenant; + Tenant = tenant; + return new Restore(() => Tenant = previousTenant); + } + + private sealed class Restore(Action restore) : IDisposable + { + public void Dispose() => restore(); + } + } +} diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/Elsa.Persistence.Dapper.UnitTests.csproj b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/Elsa.Persistence.Dapper.UnitTests.csproj index 452c337066..e80df5b766 100644 --- a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/Elsa.Persistence.Dapper.UnitTests.csproj +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/Elsa.Persistence.Dapper.UnitTests.csproj @@ -1,6 +1,7 @@  + From 2b1127fe88e0398081ded85b4340de8f03a9ec9a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 28 Sep 2026 07:11:21 +0000 Subject: [PATCH 043/169] fix(secrets): bulk delete filters by SecretId (#253) DeleteManyAsync was passing version row Ids as SecretFilter.SecretIds, which matches the logical SecretId, so bulk delete removed nothing. Select distinct SecretId so every version of each selected secret is deleted. Co-authored-by: Sipke Schoorstra --- Elsa.Extensions.sln | 18 ++++++ .../Services/DefaultSecretManager.cs | 2 +- .../DefaultSecretManagerTests.cs | 59 +++++++++++++++++++ .../Elsa.Secrets.Management.UnitTests.csproj | 7 +++ 4 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 test/modules/secrets/Elsa.Secrets.Management.UnitTests/DefaultSecretManagerTests.cs create mode 100644 test/modules/secrets/Elsa.Secrets.Management.UnitTests/Elsa.Secrets.Management.UnitTests.csproj diff --git a/Elsa.Extensions.sln b/Elsa.Extensions.sln index 56758f86ca..42aace26df 100644 --- a/Elsa.Extensions.sln +++ b/Elsa.Extensions.sln @@ -336,6 +336,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "runtimes", "runtimes", "{74 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Workflows.Runtime.ProtoActor.UnitTests", "test\modules\runtimes\Elsa.Workflows.Runtime.ProtoActor.UnitTests\Elsa.Workflows.Runtime.ProtoActor.UnitTests.csproj", "{209765CA-207B-44F0-94E4-0711235E7F99}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "secrets", "secrets", "{A1526B50-A5D1-398B-3070-5440A8E5BDFE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Secrets.Management.UnitTests", "test\modules\secrets\Elsa.Secrets.Management.UnitTests\Elsa.Secrets.Management.UnitTests.csproj", "{02CB40E6-1DC0-4AC8-B9F1-D268CA26654F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1610,6 +1614,18 @@ Global {C115D266-ED50-4584-B059-B4BB48B54C77}.Release|x64.Build.0 = Release|Any CPU {C115D266-ED50-4584-B059-B4BB48B54C77}.Release|x86.ActiveCfg = Release|Any CPU {C115D266-ED50-4584-B059-B4BB48B54C77}.Release|x86.Build.0 = Release|Any CPU + {02CB40E6-1DC0-4AC8-B9F1-D268CA26654F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {02CB40E6-1DC0-4AC8-B9F1-D268CA26654F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {02CB40E6-1DC0-4AC8-B9F1-D268CA26654F}.Debug|x64.ActiveCfg = Debug|Any CPU + {02CB40E6-1DC0-4AC8-B9F1-D268CA26654F}.Debug|x64.Build.0 = Debug|Any CPU + {02CB40E6-1DC0-4AC8-B9F1-D268CA26654F}.Debug|x86.ActiveCfg = Debug|Any CPU + {02CB40E6-1DC0-4AC8-B9F1-D268CA26654F}.Debug|x86.Build.0 = Debug|Any CPU + {02CB40E6-1DC0-4AC8-B9F1-D268CA26654F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {02CB40E6-1DC0-4AC8-B9F1-D268CA26654F}.Release|Any CPU.Build.0 = Release|Any CPU + {02CB40E6-1DC0-4AC8-B9F1-D268CA26654F}.Release|x64.ActiveCfg = Release|Any CPU + {02CB40E6-1DC0-4AC8-B9F1-D268CA26654F}.Release|x64.Build.0 = Release|Any CPU + {02CB40E6-1DC0-4AC8-B9F1-D268CA26654F}.Release|x86.ActiveCfg = Release|Any CPU + {02CB40E6-1DC0-4AC8-B9F1-D268CA26654F}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1758,6 +1774,8 @@ Global {799D5262-2BB1-C4D2-CB3D-522B0B7186CF} = {3DDE6F89-531C-47F8-9CD7-7A4E6984FA48} {1994376D-9775-4709-88E5-BE44618546AD} = {799D5262-2BB1-C4D2-CB3D-522B0B7186CF} {C115D266-ED50-4584-B059-B4BB48B54C77} = {273F8587-516E-41F3-93B9-D2F8FDC188A4} + {A1526B50-A5D1-398B-3070-5440A8E5BDFE} = {3DDE6F89-531C-47F8-9CD7-7A4E6984FA48} + {02CB40E6-1DC0-4AC8-B9F1-D268CA26654F} = {A1526B50-A5D1-398B-3070-5440A8E5BDFE} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {11A771DA-B728-445E-8A88-AE1C84C3B3A6} diff --git a/src/modules/secrets/Elsa.Secrets.Management/Services/DefaultSecretManager.cs b/src/modules/secrets/Elsa.Secrets.Management/Services/DefaultSecretManager.cs index 0c2f74eeb6..d203e55ac4 100644 --- a/src/modules/secrets/Elsa.Secrets.Management/Services/DefaultSecretManager.cs +++ b/src/modules/secrets/Elsa.Secrets.Management/Services/DefaultSecretManager.cs @@ -74,7 +74,7 @@ public async Task DeleteAsync(Secret entity, CancellationToken cancellationToken public async Task DeleteManyAsync(SecretFilter filter, CancellationToken cancellationToken = default) { var secretVersions = await store.FindManyAsync(filter, cancellationToken); - var secretIds = secretVersions.Select(x => x.Id).Distinct().ToList(); + var secretIds = secretVersions.Select(x => x.SecretId).Distinct().ToList(); var allSecretsAndVersionsFilter = new SecretFilter { SecretIds = secretIds diff --git a/test/modules/secrets/Elsa.Secrets.Management.UnitTests/DefaultSecretManagerTests.cs b/test/modules/secrets/Elsa.Secrets.Management.UnitTests/DefaultSecretManagerTests.cs new file mode 100644 index 0000000000..eaa58b5e09 --- /dev/null +++ b/test/modules/secrets/Elsa.Secrets.Management.UnitTests/DefaultSecretManagerTests.cs @@ -0,0 +1,59 @@ +using Elsa.Common; +using Elsa.Common.Services; +using Elsa.Mediator.Contracts; +using Elsa.Workflows; +using NSubstitute; + +namespace Elsa.Secrets.Management.UnitTests; + +public class DefaultSecretManagerTests +{ + [Fact] + public async Task DeleteManyAsync_WhenFilteredByVersionId_DeletesAllVersionsOfSelectedSecrets() + { + // Arrange + var store = new MemorySecretStore(new MemoryStore()); + var selectedV1 = CreateVersion("selected-v1", "secret-selected", version: 1, isLatest: false); + var selectedV2 = CreateVersion("selected-v2", "secret-selected", version: 2, isLatest: true); + var otherV1 = CreateVersion("other-v1", "secret-other", version: 1, isLatest: false); + var otherV2 = CreateVersion("other-v2", "secret-other", version: 2, isLatest: true); + + await store.AddAsync(selectedV1); + await store.AddAsync(selectedV2); + await store.AddAsync(otherV1); + await store.AddAsync(otherV2); + + var manager = new DefaultSecretManager( + store, + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For()); + + // Act + var count = await manager.DeleteManyAsync(new SecretFilter + { + Ids = [selectedV2.Id] + }); + + // Assert + var remaining = (await store.ListAsync()).ToList(); + + Assert.Equal(2, count); + Assert.DoesNotContain(remaining, x => x.SecretId == selectedV1.SecretId); + Assert.Equal(2, remaining.Count); + Assert.Contains(remaining, x => x.Id == otherV1.Id); + Assert.Contains(remaining, x => x.Id == otherV2.Id); + } + + private static Secret CreateVersion(string id, string secretId, int version, bool isLatest) => new() + { + Id = id, + SecretId = secretId, + Name = secretId, + EncryptedValue = $"value-{version}", + Version = version, + IsLatest = isLatest + }; +} diff --git a/test/modules/secrets/Elsa.Secrets.Management.UnitTests/Elsa.Secrets.Management.UnitTests.csproj b/test/modules/secrets/Elsa.Secrets.Management.UnitTests/Elsa.Secrets.Management.UnitTests.csproj new file mode 100644 index 0000000000..27c3fa9c74 --- /dev/null +++ b/test/modules/secrets/Elsa.Secrets.Management.UnitTests/Elsa.Secrets.Management.UnitTests.csproj @@ -0,0 +1,7 @@ + + + + + + + From aa77da677204f68fd7e48cb4721f1c4fdfa6ac22 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 28 Sep 2026 07:12:01 +0000 Subject: [PATCH 044/169] fix(dapper): match both Postgres and PostgreSQL provider names in migrations FluentMigrator 7.2 AddPostgres() uses Postgres15_0Processor, whose DatabaseType is PostgreSQL15_0 and aliases are PostgreSQL15_0/PostgreSQL, not Postgres. IfDatabase("Postgres") therefore skipped every create-table branch and a fresh PG migrate failed at 10002. Accept both names so older and 7.2 processors keep working, and add a Testcontainers PostgreSQL fresh-DB migration test. Co-authored-by: Sipke Schoorstra --- Directory.Packages.props | 1 + .../Management/Initial.cs | 4 +- .../Runtime/Initial.cs | 8 +- .../Runtime/V3_3.cs | 2 +- .../DapperPostgreSqlMigrationTests.cs | 151 ++++++++++++++++++ .../DapperPostgresProcessorNameTests.cs | 40 +++++ .../Elsa.Persistence.Dapper.UnitTests.csproj | 3 + 7 files changed, 202 insertions(+), 7 deletions(-) create mode 100644 test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs create mode 100644 test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgresProcessorNameTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 80cc818159..d3d59ee557 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -164,6 +164,7 @@ + diff --git a/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Management/Initial.cs b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Management/Initial.cs index d44a0e93d1..a9740e9f34 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Management/Initial.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Management/Initial.cs @@ -12,7 +12,7 @@ public class Initial : Migration /// public override void Up() { - IfDatabase("SqlServer", "Oracle", "MySql", "Postgres") + IfDatabase("SqlServer", "Oracle", "MySql", "Postgres", "PostgreSQL") .Create .Table("WorkflowDefinitions") .WithColumn("Id").AsString().PrimaryKey() @@ -55,7 +55,7 @@ public override void Up() .WithColumn("IsReadonly").AsBoolean().NotNullable() .WithColumn("IsPublished").AsBoolean().NotNullable(); - IfDatabase("SqlServer", "Oracle", "MySql", "Postgres") + IfDatabase("SqlServer", "Oracle", "MySql", "Postgres", "PostgreSQL") .Create .Table("WorkflowInstances") .WithColumn("Id").AsString().PrimaryKey() diff --git a/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/Initial.cs b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/Initial.cs index 5e47265259..ee19432a78 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/Initial.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/Initial.cs @@ -23,7 +23,7 @@ public override void Up() .WithColumn("Hash").AsString().Nullable().Indexed() .WithColumn("SerializedPayload").AsString(MaxValue).Nullable(); - IfDatabase("SqlServer", "Oracle", "MySql", "Postgres") + IfDatabase("SqlServer", "Oracle", "MySql", "Postgres", "PostgreSQL") .Create .Table("Bookmarks") .WithColumn("Id").AsString().PrimaryKey() @@ -49,7 +49,7 @@ public override void Up() .WithColumn("SerializedMetadata").AsString(MaxValue).Nullable() .WithColumn("CreatedAt").AsDateTime2().NotNullable().Indexed(); - IfDatabase("SqlServer", "Oracle", "MySql", "Postgres") + IfDatabase("SqlServer", "Oracle", "MySql", "Postgres", "PostgreSQL") .Create .Table("WorkflowExecutionLogRecords") .WithColumn("Id").AsString().PrimaryKey() @@ -100,7 +100,7 @@ public override void Up() .WithColumn("Sequence").AsInt64().NotNullable().Indexed() ; - IfDatabase("SqlServer", "Oracle", "MySql", "Postgres") + IfDatabase("SqlServer", "Oracle", "MySql", "Postgres", "PostgreSQL") .Create .Table("ActivityExecutionRecords") .WithColumn("Id").AsString().PrimaryKey() @@ -140,7 +140,7 @@ public override void Up() .WithColumn("Status").AsString().NotNullable().Indexed() ; - IfDatabase("SqlServer", "Oracle", "MySql", "Postgres") + IfDatabase("SqlServer", "Oracle", "MySql", "Postgres", "PostgreSQL") .Create .Table("WorkflowInboxMessages") .WithColumn("Id").AsString().PrimaryKey() diff --git a/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/V3_3.cs b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/V3_3.cs index c720590fd4..84e50e760c 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/V3_3.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/V3_3.cs @@ -21,7 +21,7 @@ public override void Up() Alter.Table("ActivityExecutionRecords").AddColumn("SerializedProperties").AsString(MaxValue).Nullable(); Alter.Table("KeyValuePairs").AddColumn("TenantId").AsString().Nullable(); - IfDatabase("SqlServer", "Oracle", "MySql", "Postgres") + IfDatabase("SqlServer", "Oracle", "MySql", "Postgres", "PostgreSQL") .Create .Table("BookmarkQueueItems") .WithColumn("Id").AsString().PrimaryKey() diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs new file mode 100644 index 0000000000..860a43cdd0 --- /dev/null +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs @@ -0,0 +1,151 @@ +using Dapper; +using Elsa.Common.Multitenancy; +using Elsa.Persistence.Dapper.Extensions; +using Elsa.Persistence.Dapper.Modules.Runtime.Records; +using Elsa.Persistence.Dapper.Services; +using FluentMigrator.Runner; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; +using Testcontainers.PostgreSql; + +namespace Elsa.Persistence.Dapper.UnitTests; + +/// +/// Runs the Dapper FluentMigrator assembly against a fresh PostgreSQL database. +/// Covers the FluentMigrator 7.2 IfDatabase("Postgres") vs "PostgreSQL" mismatch from issue #255. +/// +public sealed class DapperPostgreSqlMigrationTests : IAsyncLifetime +{ + private readonly PostgreSqlContainer _container = new PostgreSqlBuilder() + .WithImage("postgres:16") + .WithDatabase("elsa") + .WithUsername("elsa") + .WithPassword("elsa") + .Build(); + + private string _connectionString = null!; + + public async Task InitializeAsync() + { + try + { + await _container.StartAsync(); + _connectionString = _container.GetConnectionString(); + } + catch + { + await DisposeAsync(); + throw; + } + } + + public async Task DisposeAsync() + { + await _container.DisposeAsync(); + } + + [Fact(DisplayName = "A fresh PostgreSQL DB applies every Dapper migration and can persist SerializedMetadata + AggregateFaultCount")] + public async Task FreshPostgreSqlDatabase_MigratesAndPersistsActivityExecutionRecord() + { + // Arrange + var store = CreateActivityExecutionStore(); + var record = new ActivityExecutionRecordRecord + { + Id = "rec-1", + WorkflowInstanceId = "wf-1", + ActivityId = "act-1", + ActivityNodeId = "node-1", + ActivityType = "Elsa.WriteLine", + ActivityTypeVersion = 1, + ActivityName = "WriteLine", + StartedAt = DateTimeOffset.UtcNow, + HasBookmarks = false, + Status = "Finished", + SerializedMetadata = """{"source":"fresh-db"}""", + AggregateFaultCount = 2 + }; + + // Act + MigrateUp(); + await store.SaveAsync(record); + var loaded = await store.FindAsync(q => q.Is(nameof(ActivityExecutionRecordRecord.Id), record.Id), cancellationToken: CancellationToken.None); + + // Assert + var columns = ActivityExecutionRecordColumns(); + Assert.Contains(columns, name => name.Equals("AggregateFaultCount", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(columns, name => name.Equals("SerializedMetadata", StringComparison.OrdinalIgnoreCase)); + Assert.True(TableExists("Roles")); + Assert.True(TableExists("WorkflowDefinitions")); + Assert.True(TableExists("WorkflowInstances")); + Assert.True(TableExists("Bookmarks")); + Assert.True(TableExists("ActivityExecutionRecords")); + Assert.True(TableExists("BookmarkQueueItems")); + Assert.NotNull(loaded); + Assert.Equal(2, loaded.AggregateFaultCount); + Assert.Equal("""{"source":"fresh-db"}""", loaded.SerializedMetadata); + } + + private Store CreateActivityExecutionStore() + { + var connectionProvider = new PostgreSqlDbConnectionProvider(_connectionString); + return new Store(connectionProvider, new TestTenantAccessor(), "ActivityExecutionRecords"); + } + + private void MigrateUp() + { + using var services = CreateMigrator(); + using var scope = services.CreateScope(); + var runner = scope.ServiceProvider.GetRequiredService(); + runner.MigrateUp(); + } + + private ServiceProvider CreateMigrator() => + new ServiceCollection() + .AddFluentMigratorCore() + .ConfigureRunner(rb => rb + .AddPostgres() + .WithGlobalConnectionString(_connectionString) + .ScanIn(typeof(Elsa.Persistence.Dapper.Migrations.Runtime.Initial).Assembly).For.Migrations()) + .BuildServiceProvider(false); + + private IReadOnlyCollection ActivityExecutionRecordColumns() + { + using var connection = new NpgsqlConnection(_connectionString); + return connection.Query( + """ + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name ILIKE 'ActivityExecutionRecords' + """).ToList(); + } + + private bool TableExists(string tableName) + { + using var connection = new NpgsqlConnection(_connectionString); + return connection.ExecuteScalar( + """ + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = 'public' AND table_name ILIKE @tableName + """, + new { tableName }) == 1; + } + + private sealed class TestTenantAccessor : ITenantAccessor + { + public string TenantId => Tenant?.Id ?? Tenant.DefaultTenantId; + public Tenant? Tenant { get; private set; } + + public IDisposable PushContext(Tenant? tenant) + { + var previousTenant = Tenant; + Tenant = tenant; + return new Restore(() => Tenant = previousTenant); + } + + private sealed class Restore(Action restore) : IDisposable + { + public void Dispose() => restore(); + } + } +} diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgresProcessorNameTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgresProcessorNameTests.cs new file mode 100644 index 0000000000..d4697e7a69 --- /dev/null +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgresProcessorNameTests.cs @@ -0,0 +1,40 @@ +using FluentMigrator.Runner; +using FluentMigrator.Runner.Processors; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Persistence.Dapper.UnitTests; + +/// +/// Documents FluentMigrator 7.2's AddPostgres() processor identifiers. +/// The Dapper IfDatabase lists must include both "Postgres" and "PostgreSQL" +/// because 7.2 reports the latter (and "PostgreSQL15_0") but not the former. +/// +public sealed class DapperPostgresProcessorNameTests +{ + [Fact(DisplayName = "FluentMigrator 7.2 AddPostgres() reports PostgreSQL15_0 / PostgreSQL, not Postgres")] + public void AddPostgres_Processor_ReportsPostgreSQL_NotPostgres() + { + using var services = new ServiceCollection() + .AddFluentMigratorCore() + .ConfigureRunner(rb => rb + .AddPostgres() + .WithGlobalConnectionString("Host=localhost;Database=unused")) + .BuildServiceProvider(false); + using var scope = services.CreateScope(); + var processor = scope.ServiceProvider.GetRequiredService(); + + var names = new HashSet(processor.DatabaseTypeAliases, StringComparer.OrdinalIgnoreCase) + { + processor.DatabaseType + }; + + Assert.Equal("PostgreSQL15_0", processor.DatabaseType); + Assert.Contains("PostgreSQL", names); + Assert.DoesNotContain("Postgres", names); + + var legacyGuard = new[] { "SqlServer", "Oracle", "MySql", "Postgres" }; + var fixedGuard = new[] { "SqlServer", "Oracle", "MySql", "Postgres", "PostgreSQL" }; + Assert.False(legacyGuard.Any(names.Contains), "IfDatabase(\"Postgres\") must not match FluentMigrator 7.2 AddPostgres()."); + Assert.True(fixedGuard.Any(names.Contains), "IfDatabase(..., \"PostgreSQL\") must match FluentMigrator 7.2 AddPostgres()."); + } +} diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/Elsa.Persistence.Dapper.UnitTests.csproj b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/Elsa.Persistence.Dapper.UnitTests.csproj index e80df5b766..b252f6b95d 100644 --- a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/Elsa.Persistence.Dapper.UnitTests.csproj +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/Elsa.Persistence.Dapper.UnitTests.csproj @@ -1,8 +1,11 @@  + + + From 87425a2fab86b685c0e617aa9aa421fabac4d973 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 28 Sep 2026 07:14:54 +0000 Subject: [PATCH 045/169] test(dapper): fix IMigrationProcessor namespace in processor-name test IMigrationProcessor lives in the FluentMigrator namespace in 7.2, not FluentMigrator.Runner.Processors. Co-authored-by: Sipke Schoorstra --- .../DapperPostgresProcessorNameTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgresProcessorNameTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgresProcessorNameTests.cs index d4697e7a69..8530c4056d 100644 --- a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgresProcessorNameTests.cs +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgresProcessorNameTests.cs @@ -1,5 +1,5 @@ +using FluentMigrator; using FluentMigrator.Runner; -using FluentMigrator.Runner.Processors; using Microsoft.Extensions.DependencyInjection; namespace Elsa.Persistence.Dapper.UnitTests; From e1bc44a9e65ba9c724e66a7d4bc78f5690505f63 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 28 Sep 2026 07:19:30 +0000 Subject: [PATCH 046/169] fix(dapper): match every FluentMigrator 7.2 PostgreSQL* processor name IfDatabase(string[]) is an exact OrdinalIgnoreCase match against DatabaseType plus aliases, so listing only "PostgreSQL" misses PostgreSQL15_0 as a type and Postgres92 entirely. Switch the DateTimeOffset create-table branches to a predicate that matches SqlServer/Oracle/MySql exactly and any DatabaseType starting with Postgres (covers Postgres, PostgreSQL, PostgreSQL10_0/11_0/15_0, Postgres92, PostgreSQL92). Skip Create.Table when the table already exists so a database that reaches 10001/20001/20004 without VersionInfo rows (manual schema or a different runner) does not fail. Recorded VersionInfo versions still do not re-run. Co-authored-by: Sipke Schoorstra --- .../Elsa.Persistence.Dapper.Migrations.csproj | 4 + .../Management/Initial.cs | 153 +++++---- .../MigrationDatabases.cs | 28 ++ .../Runtime/Initial.cs | 319 +++++++++--------- .../Runtime/V3_3.cs | 55 +-- .../DapperPostgreSqlMigrationTests.cs | 2 +- .../DapperPostgresProcessorNameTests.cs | 37 +- .../DapperRuntimeMigrationTests.cs | 16 + 8 files changed, 356 insertions(+), 258 deletions(-) create mode 100644 src/modules/persistence/Elsa.Persistence.Dapper.Migrations/MigrationDatabases.cs diff --git a/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Elsa.Persistence.Dapper.Migrations.csproj b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Elsa.Persistence.Dapper.Migrations.csproj index 6253b19977..2754df9158 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Elsa.Persistence.Dapper.Migrations.csproj +++ b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Elsa.Persistence.Dapper.Migrations.csproj @@ -11,4 +11,8 @@ + + + + diff --git a/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Management/Initial.cs b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Management/Initial.cs index a9740e9f34..1cff978ea2 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Management/Initial.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Management/Initial.cs @@ -12,82 +12,87 @@ public class Initial : Migration /// public override void Up() { - IfDatabase("SqlServer", "Oracle", "MySql", "Postgres", "PostgreSQL") - .Create - .Table("WorkflowDefinitions") - .WithColumn("Id").AsString().PrimaryKey() - .WithColumn("DefinitionId").AsString().NotNullable() - .WithColumn("Name").AsString().Nullable() - .WithColumn("ToolVersion").AsString().Nullable() - .WithColumn("Description").AsString(MaxValue).Nullable() - .WithColumn("ProviderName").AsString().Nullable() - .WithColumn("MaterializerName").AsString().NotNullable() - .WithColumn("MaterializerContext").AsString(MaxValue).Nullable() - .WithColumn("Props").AsString(MaxValue).NotNullable() - .WithColumn("UsableAsActivity").AsBoolean().Nullable() - .WithColumn("StringData").AsString(MaxValue).Nullable() - .WithColumn("BinaryData").AsBinary(MaxValue).Nullable() - .WithColumn("CreatedAt").AsDateTimeOffset().NotNullable() - .WithColumn("Version").AsInt32().NotNullable() - .WithColumn("IsLatest").AsBoolean().NotNullable() - .WithColumn("IsReadonly").AsBoolean().NotNullable() - .WithColumn("IsPublished").AsBoolean().NotNullable(); - + if (!Schema.Table("WorkflowDefinitions").Exists()) + { + IfDatabase(MigrationDatabases.IsDateTimeOffsetProvider) + .Create + .Table("WorkflowDefinitions") + .WithColumn("Id").AsString().PrimaryKey() + .WithColumn("DefinitionId").AsString().NotNullable() + .WithColumn("Name").AsString().Nullable() + .WithColumn("ToolVersion").AsString().Nullable() + .WithColumn("Description").AsString(MaxValue).Nullable() + .WithColumn("ProviderName").AsString().Nullable() + .WithColumn("MaterializerName").AsString().NotNullable() + .WithColumn("MaterializerContext").AsString(MaxValue).Nullable() + .WithColumn("Props").AsString(MaxValue).NotNullable() + .WithColumn("UsableAsActivity").AsBoolean().Nullable() + .WithColumn("StringData").AsString(MaxValue).Nullable() + .WithColumn("BinaryData").AsBinary(MaxValue).Nullable() + .WithColumn("CreatedAt").AsDateTimeOffset().NotNullable() + .WithColumn("Version").AsInt32().NotNullable() + .WithColumn("IsLatest").AsBoolean().NotNullable() + .WithColumn("IsReadonly").AsBoolean().NotNullable() + .WithColumn("IsPublished").AsBoolean().NotNullable(); - IfDatabase("Sqlite") - .Create - .Table("WorkflowDefinitions") - .WithColumn("Id").AsString().PrimaryKey() - .WithColumn("DefinitionId").AsString().NotNullable() - .WithColumn("Name").AsString().Nullable() - .WithColumn("ToolVersion").AsString().Nullable() - .WithColumn("Description").AsString(MaxValue).Nullable() - .WithColumn("ProviderName").AsString().Nullable() - .WithColumn("MaterializerName").AsString().NotNullable() - .WithColumn("MaterializerContext").AsString().Nullable() - .WithColumn("Props").AsString(MaxValue).NotNullable() - .WithColumn("UsableAsActivity").AsBoolean().Nullable() - .WithColumn("StringData").AsString(MaxValue).Nullable() - .WithColumn("BinaryData").AsBinary(MaxValue).Nullable() - .WithColumn("CreatedAt").AsDateTime2().NotNullable() - .WithColumn("Version").AsInt32().NotNullable() - .WithColumn("IsLatest").AsBoolean().NotNullable() - .WithColumn("IsReadonly").AsBoolean().NotNullable() - .WithColumn("IsPublished").AsBoolean().NotNullable(); + IfDatabase("Sqlite") + .Create + .Table("WorkflowDefinitions") + .WithColumn("Id").AsString().PrimaryKey() + .WithColumn("DefinitionId").AsString().NotNullable() + .WithColumn("Name").AsString().Nullable() + .WithColumn("ToolVersion").AsString().Nullable() + .WithColumn("Description").AsString(MaxValue).Nullable() + .WithColumn("ProviderName").AsString().Nullable() + .WithColumn("MaterializerName").AsString().NotNullable() + .WithColumn("MaterializerContext").AsString().Nullable() + .WithColumn("Props").AsString(MaxValue).NotNullable() + .WithColumn("UsableAsActivity").AsBoolean().Nullable() + .WithColumn("StringData").AsString(MaxValue).Nullable() + .WithColumn("BinaryData").AsBinary(MaxValue).Nullable() + .WithColumn("CreatedAt").AsDateTime2().NotNullable() + .WithColumn("Version").AsInt32().NotNullable() + .WithColumn("IsLatest").AsBoolean().NotNullable() + .WithColumn("IsReadonly").AsBoolean().NotNullable() + .WithColumn("IsPublished").AsBoolean().NotNullable(); + } - IfDatabase("SqlServer", "Oracle", "MySql", "Postgres", "PostgreSQL") - .Create - .Table("WorkflowInstances") - .WithColumn("Id").AsString().PrimaryKey() - .WithColumn("DefinitionId").AsString().NotNullable() - .WithColumn("DefinitionVersionId").AsString().NotNullable() - .WithColumn("Version").AsInt32().NotNullable() - .WithColumn("WorkflowState").AsString(MaxValue).NotNullable() - .WithColumn("Status").AsString().NotNullable() - .WithColumn("SubStatus").AsString().NotNullable() - .WithColumn("CorrelationId").AsString().Nullable() - .WithColumn("Name").AsString().Nullable() - .WithColumn("IncidentCount").AsInt32().NotNullable() - .WithColumn("CreatedAt").AsDateTimeOffset().NotNullable() - .WithColumn("UpdatedAt").AsDateTimeOffset().Nullable() - .WithColumn("FinishedAt").AsDateTimeOffset().Nullable(); + if (!Schema.Table("WorkflowInstances").Exists()) + { + IfDatabase(MigrationDatabases.IsDateTimeOffsetProvider) + .Create + .Table("WorkflowInstances") + .WithColumn("Id").AsString().PrimaryKey() + .WithColumn("DefinitionId").AsString().NotNullable() + .WithColumn("DefinitionVersionId").AsString().NotNullable() + .WithColumn("Version").AsInt32().NotNullable() + .WithColumn("WorkflowState").AsString(MaxValue).NotNullable() + .WithColumn("Status").AsString().NotNullable() + .WithColumn("SubStatus").AsString().NotNullable() + .WithColumn("CorrelationId").AsString().Nullable() + .WithColumn("Name").AsString().Nullable() + .WithColumn("IncidentCount").AsInt32().NotNullable() + .WithColumn("CreatedAt").AsDateTimeOffset().NotNullable() + .WithColumn("UpdatedAt").AsDateTimeOffset().Nullable() + .WithColumn("FinishedAt").AsDateTimeOffset().Nullable(); - IfDatabase("Sqlite") - .Create - .Table("WorkflowInstances") - .WithColumn("Id").AsString().PrimaryKey() - .WithColumn("DefinitionId").AsString().NotNullable() - .WithColumn("DefinitionVersionId").AsString().NotNullable() - .WithColumn("Version").AsInt32().NotNullable() - .WithColumn("WorkflowState").AsString(MaxValue).NotNullable() - .WithColumn("Status").AsString().NotNullable() - .WithColumn("SubStatus").AsString().NotNullable() - .WithColumn("CorrelationId").AsString().Nullable() - .WithColumn("Name").AsString().Nullable() - .WithColumn("IncidentCount").AsInt32().NotNullable() - .WithColumn("CreatedAt").AsDateTime2().NotNullable() - .WithColumn("UpdatedAt").AsDateTime2().Nullable() - .WithColumn("FinishedAt").AsDateTime2().Nullable(); + IfDatabase("Sqlite") + .Create + .Table("WorkflowInstances") + .WithColumn("Id").AsString().PrimaryKey() + .WithColumn("DefinitionId").AsString().NotNullable() + .WithColumn("DefinitionVersionId").AsString().NotNullable() + .WithColumn("Version").AsInt32().NotNullable() + .WithColumn("WorkflowState").AsString(MaxValue).NotNullable() + .WithColumn("Status").AsString().NotNullable() + .WithColumn("SubStatus").AsString().NotNullable() + .WithColumn("CorrelationId").AsString().Nullable() + .WithColumn("Name").AsString().Nullable() + .WithColumn("IncidentCount").AsInt32().NotNullable() + .WithColumn("CreatedAt").AsDateTime2().NotNullable() + .WithColumn("UpdatedAt").AsDateTime2().Nullable() + .WithColumn("FinishedAt").AsDateTime2().Nullable(); + } } /// @@ -96,4 +101,4 @@ public override void Down() Delete.Table("WorkflowDefinitions"); Delete.Table("WorkflowInstances"); } -} \ No newline at end of file +} diff --git a/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/MigrationDatabases.cs b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/MigrationDatabases.cs new file mode 100644 index 0000000000..b6461c4e08 --- /dev/null +++ b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/MigrationDatabases.cs @@ -0,0 +1,28 @@ +namespace Elsa.Persistence.Dapper.Migrations; + +/// +/// Provider matching for Dapper IfDatabase create-table branches. +/// +/// +/// +/// IfDatabase(params string[]) is an exact +/// match against the processor's DatabaseType and DatabaseTypeAliases. +/// It is not a prefix match. +/// +/// +/// IfDatabase(Predicate<string>) receives only DatabaseType (aliases are ignored). +/// FluentMigrator 7.2 postgres processors all report a DatabaseType that starts with +/// Postgres: Postgres, PostgreSQL, PostgreSQL10_0, PostgreSQL11_0, +/// PostgreSQL15_0, Postgres92, PostgreSQL92. A case-insensitive prefix check +/// therefore matches every 7.2 postgres processor, including AddPostgres()'s +/// PostgreSQL15_0, without relying on aliases. +/// +/// +internal static class MigrationDatabases +{ + public static bool IsDateTimeOffsetProvider(string databaseType) => + databaseType.Equals("SqlServer", StringComparison.OrdinalIgnoreCase) + || databaseType.Equals("Oracle", StringComparison.OrdinalIgnoreCase) + || databaseType.Equals("MySql", StringComparison.OrdinalIgnoreCase) + || databaseType.StartsWith("Postgres", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/Initial.cs b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/Initial.cs index ee19432a78..e2e43d0d41 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/Initial.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/Initial.cs @@ -13,162 +13,177 @@ public class Initial : Migration /// public override void Up() { - Create - .Table("Triggers") - .WithColumn("Id").AsString().PrimaryKey() - .WithColumn("WorkflowDefinitionId").AsString().NotNullable().Indexed() - .WithColumn("WorkflowDefinitionVersionId").AsString().NotNullable().Indexed() - .WithColumn("Name").AsString().NotNullable().Indexed() - .WithColumn("ActivityId").AsString().NotNullable().Indexed() - .WithColumn("Hash").AsString().Nullable().Indexed() - .WithColumn("SerializedPayload").AsString(MaxValue).Nullable(); + if (!Schema.Table("Triggers").Exists()) + { + Create + .Table("Triggers") + .WithColumn("Id").AsString().PrimaryKey() + .WithColumn("WorkflowDefinitionId").AsString().NotNullable().Indexed() + .WithColumn("WorkflowDefinitionVersionId").AsString().NotNullable().Indexed() + .WithColumn("Name").AsString().NotNullable().Indexed() + .WithColumn("ActivityId").AsString().NotNullable().Indexed() + .WithColumn("Hash").AsString().Nullable().Indexed() + .WithColumn("SerializedPayload").AsString(MaxValue).Nullable(); + } - IfDatabase("SqlServer", "Oracle", "MySql", "Postgres", "PostgreSQL") - .Create - .Table("Bookmarks") - .WithColumn("Id").AsString().PrimaryKey() - .WithColumn("ActivityTypeName").AsString().NotNullable().Indexed() - .WithColumn("WorkflowInstanceId").AsString().NotNullable().Indexed() - .WithColumn("CorrelationId").AsString().Nullable().Indexed() - .WithColumn("ActivityInstanceId").AsString().NotNullable().Indexed() - .WithColumn("Hash").AsString().Nullable().Indexed() - .WithColumn("SerializedPayload").AsString(MaxValue).Nullable() - .WithColumn("SerializedMetadata").AsString(MaxValue).Nullable() - .WithColumn("CreatedAt").AsDateTimeOffset().NotNullable().Indexed(); - - IfDatabase("Sqlite") - .Create - .Table("Bookmarks") - .WithColumn("Id").AsString().PrimaryKey() - .WithColumn("ActivityTypeName").AsString().NotNullable().Indexed() - .WithColumn("WorkflowInstanceId").AsString().NotNullable().Indexed() - .WithColumn("CorrelationId").AsString().Nullable().Indexed() - .WithColumn("ActivityInstanceId").AsString().NotNullable().Indexed() - .WithColumn("Hash").AsString().Nullable().Indexed() - .WithColumn("SerializedPayload").AsString(MaxValue).Nullable() - .WithColumn("SerializedMetadata").AsString(MaxValue).Nullable() - .WithColumn("CreatedAt").AsDateTime2().NotNullable().Indexed(); + if (!Schema.Table("Bookmarks").Exists()) + { + IfDatabase(MigrationDatabases.IsDateTimeOffsetProvider) + .Create + .Table("Bookmarks") + .WithColumn("Id").AsString().PrimaryKey() + .WithColumn("ActivityTypeName").AsString().NotNullable().Indexed() + .WithColumn("WorkflowInstanceId").AsString().NotNullable().Indexed() + .WithColumn("CorrelationId").AsString().Nullable().Indexed() + .WithColumn("ActivityInstanceId").AsString().NotNullable().Indexed() + .WithColumn("Hash").AsString().Nullable().Indexed() + .WithColumn("SerializedPayload").AsString(MaxValue).Nullable() + .WithColumn("SerializedMetadata").AsString(MaxValue).Nullable() + .WithColumn("CreatedAt").AsDateTimeOffset().NotNullable().Indexed(); - IfDatabase("SqlServer", "Oracle", "MySql", "Postgres", "PostgreSQL") - .Create - .Table("WorkflowExecutionLogRecords") - .WithColumn("Id").AsString().PrimaryKey() - .WithColumn("WorkflowDefinitionId").AsString().NotNullable().Indexed() - .WithColumn("WorkflowDefinitionVersionId").AsString().NotNullable().Indexed() - .WithColumn("WorkflowInstanceId").AsString().NotNullable().Indexed() - .WithColumn("WorkflowVersion").AsInt32().NotNullable().Indexed() - .WithColumn("ActivityInstanceId").AsString().NotNullable().Indexed() - .WithColumn("ParentActivityInstanceId").AsString().Nullable().Indexed() - .WithColumn("ActivityId").AsString().NotNullable().Indexed() - .WithColumn("ActivityType").AsString().NotNullable().Indexed() - .WithColumn("ActivityTypeVersion").AsInt32().NotNullable().Indexed() - .WithColumn("ActivityName").AsString().Nullable().Indexed() - .WithColumn("ActivityNodeId").AsString(MaxNodeIdColumnLength).NotNullable().Indexed() - .WithColumn("EventName").AsString().Nullable().Indexed() - .WithColumn("Message").AsString(MaxValue).Nullable() - .WithColumn("Source").AsString().Nullable() - .WithColumn("SerializedActivityState").AsString(MaxValue).Nullable() - .WithColumn("SerializedPayload").AsString(MaxValue).Nullable() - .WithColumn("SerializedException").AsString(MaxValue).Nullable() - .WithColumn("Timestamp").AsDateTimeOffset().NotNullable().Indexed() - .WithColumn("Sequence").AsInt64().NotNullable().Indexed() - ; + IfDatabase("Sqlite") + .Create + .Table("Bookmarks") + .WithColumn("Id").AsString().PrimaryKey() + .WithColumn("ActivityTypeName").AsString().NotNullable().Indexed() + .WithColumn("WorkflowInstanceId").AsString().NotNullable().Indexed() + .WithColumn("CorrelationId").AsString().Nullable().Indexed() + .WithColumn("ActivityInstanceId").AsString().NotNullable().Indexed() + .WithColumn("Hash").AsString().Nullable().Indexed() + .WithColumn("SerializedPayload").AsString(MaxValue).Nullable() + .WithColumn("SerializedMetadata").AsString(MaxValue).Nullable() + .WithColumn("CreatedAt").AsDateTime2().NotNullable().Indexed(); + } - IfDatabase("Sqlite") - .Create - .Table("WorkflowExecutionLogRecords") - .WithColumn("Id").AsString().PrimaryKey() - .WithColumn("WorkflowDefinitionId").AsString().NotNullable().Indexed() - .WithColumn("WorkflowDefinitionVersionId").AsString().NotNullable().Indexed() - .WithColumn("WorkflowInstanceId").AsString().NotNullable().Indexed() - .WithColumn("WorkflowVersion").AsInt32().NotNullable().Indexed() - .WithColumn("ActivityInstanceId").AsString().NotNullable().Indexed() - .WithColumn("ParentActivityInstanceId").AsString().Nullable().Indexed() - .WithColumn("ActivityId").AsString().NotNullable().Indexed() - .WithColumn("ActivityType").AsString().NotNullable().Indexed() - .WithColumn("ActivityTypeVersion").AsInt32().NotNullable().Indexed() - .WithColumn("ActivityName").AsString().Nullable().Indexed() - .WithColumn("ActivityNodeId").AsString(MaxNodeIdColumnLength).NotNullable().Indexed() - .WithColumn("EventName").AsString().Nullable().Indexed() - .WithColumn("Message").AsString(MaxValue).Nullable() - .WithColumn("Source").AsString().Nullable() - .WithColumn("SerializedActivityState").AsString(MaxValue).Nullable() - .WithColumn("SerializedPayload").AsString(MaxValue).Nullable() - .WithColumn("SerializedOutputs").AsString(MaxValue).Nullable() - .WithColumn("SerializedException").AsString(MaxValue).Nullable() - .WithColumn("Timestamp").AsDateTime2().NotNullable().Indexed() - .WithColumn("Sequence").AsInt64().NotNullable().Indexed() - ; - - IfDatabase("SqlServer", "Oracle", "MySql", "Postgres", "PostgreSQL") - .Create - .Table("ActivityExecutionRecords") - .WithColumn("Id").AsString().PrimaryKey() - .WithColumn("WorkflowInstanceId").AsString().NotNullable().Indexed() - .WithColumn("ActivityId").AsString().NotNullable().Indexed() - .WithColumn("ActivityNodeId").AsString(MaxNodeIdColumnLength).NotNullable().Indexed() - .WithColumn("ActivityType").AsString().NotNullable().Indexed() - .WithColumn("ActivityTypeVersion").AsInt32().NotNullable().Indexed() - .WithColumn("ActivityName").AsString().Nullable().Indexed() - .WithColumn("SerializedActivityState").AsString(MaxValue).Nullable() - .WithColumn("SerializedPayload").AsString(MaxValue).Nullable() - .WithColumn("SerializedOutputs").AsString(MaxValue).Nullable() - .WithColumn("SerializedException").AsString(MaxValue).Nullable() - .WithColumn("StartedAt").AsDateTimeOffset().NotNullable().Indexed() - .WithColumn("CompletedAt").AsDateTimeOffset().Nullable().Indexed() - .WithColumn("HasBookmarks").AsBoolean().NotNullable().Indexed() - .WithColumn("Status").AsString().NotNullable().Indexed() - ; + if (!Schema.Table("WorkflowExecutionLogRecords").Exists()) + { + IfDatabase(MigrationDatabases.IsDateTimeOffsetProvider) + .Create + .Table("WorkflowExecutionLogRecords") + .WithColumn("Id").AsString().PrimaryKey() + .WithColumn("WorkflowDefinitionId").AsString().NotNullable().Indexed() + .WithColumn("WorkflowDefinitionVersionId").AsString().NotNullable().Indexed() + .WithColumn("WorkflowInstanceId").AsString().NotNullable().Indexed() + .WithColumn("WorkflowVersion").AsInt32().NotNullable().Indexed() + .WithColumn("ActivityInstanceId").AsString().NotNullable().Indexed() + .WithColumn("ParentActivityInstanceId").AsString().Nullable().Indexed() + .WithColumn("ActivityId").AsString().NotNullable().Indexed() + .WithColumn("ActivityType").AsString().NotNullable().Indexed() + .WithColumn("ActivityTypeVersion").AsInt32().NotNullable().Indexed() + .WithColumn("ActivityName").AsString().Nullable().Indexed() + .WithColumn("ActivityNodeId").AsString(MaxNodeIdColumnLength).NotNullable().Indexed() + .WithColumn("EventName").AsString().Nullable().Indexed() + .WithColumn("Message").AsString(MaxValue).Nullable() + .WithColumn("Source").AsString().Nullable() + .WithColumn("SerializedActivityState").AsString(MaxValue).Nullable() + .WithColumn("SerializedPayload").AsString(MaxValue).Nullable() + .WithColumn("SerializedException").AsString(MaxValue).Nullable() + .WithColumn("Timestamp").AsDateTimeOffset().NotNullable().Indexed() + .WithColumn("Sequence").AsInt64().NotNullable().Indexed() + ; - IfDatabase("Sqlite") - .Create - .Table("ActivityExecutionRecords") - .WithColumn("Id").AsString().PrimaryKey() - .WithColumn("WorkflowInstanceId").AsString().NotNullable().Indexed() - .WithColumn("ActivityId").AsString().NotNullable().Indexed() - .WithColumn("ActivityNodeId").AsString(MaxNodeIdColumnLength).NotNullable().Indexed() - .WithColumn("ActivityType").AsString().NotNullable().Indexed() - .WithColumn("ActivityTypeVersion").AsInt32().NotNullable().Indexed() - .WithColumn("ActivityName").AsString().Nullable().Indexed() - .WithColumn("SerializedActivityState").AsString(MaxValue).Nullable() - .WithColumn("SerializedPayload").AsString(MaxValue).Nullable() - .WithColumn("SerializedOutputs").AsString(MaxValue).Nullable() - .WithColumn("SerializedException").AsString(MaxValue).Nullable() - .WithColumn("StartedAt").AsDateTime2().NotNullable().Indexed() - .WithColumn("CompletedAt").AsDateTime2().Nullable().Indexed() - .WithColumn("HasBookmarks").AsBoolean().NotNullable().Indexed() - .WithColumn("Status").AsString().NotNullable().Indexed() - ; + IfDatabase("Sqlite") + .Create + .Table("WorkflowExecutionLogRecords") + .WithColumn("Id").AsString().PrimaryKey() + .WithColumn("WorkflowDefinitionId").AsString().NotNullable().Indexed() + .WithColumn("WorkflowDefinitionVersionId").AsString().NotNullable().Indexed() + .WithColumn("WorkflowInstanceId").AsString().NotNullable().Indexed() + .WithColumn("WorkflowVersion").AsInt32().NotNullable().Indexed() + .WithColumn("ActivityInstanceId").AsString().NotNullable().Indexed() + .WithColumn("ParentActivityInstanceId").AsString().Nullable().Indexed() + .WithColumn("ActivityId").AsString().NotNullable().Indexed() + .WithColumn("ActivityType").AsString().NotNullable().Indexed() + .WithColumn("ActivityTypeVersion").AsInt32().NotNullable().Indexed() + .WithColumn("ActivityName").AsString().Nullable().Indexed() + .WithColumn("ActivityNodeId").AsString(MaxNodeIdColumnLength).NotNullable().Indexed() + .WithColumn("EventName").AsString().Nullable().Indexed() + .WithColumn("Message").AsString(MaxValue).Nullable() + .WithColumn("Source").AsString().Nullable() + .WithColumn("SerializedActivityState").AsString(MaxValue).Nullable() + .WithColumn("SerializedPayload").AsString(MaxValue).Nullable() + .WithColumn("SerializedOutputs").AsString(MaxValue).Nullable() + .WithColumn("SerializedException").AsString(MaxValue).Nullable() + .WithColumn("Timestamp").AsDateTime2().NotNullable().Indexed() + .WithColumn("Sequence").AsInt64().NotNullable().Indexed() + ; + } - IfDatabase("SqlServer", "Oracle", "MySql", "Postgres", "PostgreSQL") - .Create - .Table("WorkflowInboxMessages") - .WithColumn("Id").AsString().PrimaryKey() - .WithColumn("ActivityTypeName").AsString().NotNullable().Indexed() - .WithColumn("WorkflowInstanceId").AsString().Nullable().Indexed() - .WithColumn("ActivityInstanceId").AsString().Nullable().Indexed() - .WithColumn("CorrelationId").AsString().Nullable().Indexed() - .WithColumn("Hash").AsString().NotNullable().Indexed() - .WithColumn("SerializedBookmarkPayload").AsString(MaxValue) - .WithColumn("SerializedInput").AsString(MaxValue).Nullable() - .WithColumn("CreatedAt").AsDateTimeOffset().Indexed() - .WithColumn("ExpiresAt").AsDateTimeOffset().Indexed() - ; - - IfDatabase("Sqlite") - .Create - .Table("WorkflowInboxMessages") - .WithColumn("Id").AsString().PrimaryKey() - .WithColumn("ActivityTypeName").AsString().NotNullable().Indexed() - .WithColumn("WorkflowInstanceId").AsString().Nullable().Indexed() - .WithColumn("ActivityInstanceId").AsString().Nullable().Indexed() - .WithColumn("CorrelationId").AsString().Nullable().Indexed() - .WithColumn("Hash").AsString().NotNullable().Indexed() - .WithColumn("SerializedBookmarkPayload").AsString(MaxValue) - .WithColumn("SerializedInput").AsString(MaxValue).Nullable() - .WithColumn("CreatedAt").AsDateTime2().Indexed() - .WithColumn("ExpiresAt").AsDateTime2().Indexed() - ; + if (!Schema.Table("ActivityExecutionRecords").Exists()) + { + IfDatabase(MigrationDatabases.IsDateTimeOffsetProvider) + .Create + .Table("ActivityExecutionRecords") + .WithColumn("Id").AsString().PrimaryKey() + .WithColumn("WorkflowInstanceId").AsString().NotNullable().Indexed() + .WithColumn("ActivityId").AsString().NotNullable().Indexed() + .WithColumn("ActivityNodeId").AsString(MaxNodeIdColumnLength).NotNullable().Indexed() + .WithColumn("ActivityType").AsString().NotNullable().Indexed() + .WithColumn("ActivityTypeVersion").AsInt32().NotNullable().Indexed() + .WithColumn("ActivityName").AsString().Nullable().Indexed() + .WithColumn("SerializedActivityState").AsString(MaxValue).Nullable() + .WithColumn("SerializedPayload").AsString(MaxValue).Nullable() + .WithColumn("SerializedOutputs").AsString(MaxValue).Nullable() + .WithColumn("SerializedException").AsString(MaxValue).Nullable() + .WithColumn("StartedAt").AsDateTimeOffset().NotNullable().Indexed() + .WithColumn("CompletedAt").AsDateTimeOffset().Nullable().Indexed() + .WithColumn("HasBookmarks").AsBoolean().NotNullable().Indexed() + .WithColumn("Status").AsString().NotNullable().Indexed() + ; + + IfDatabase("Sqlite") + .Create + .Table("ActivityExecutionRecords") + .WithColumn("Id").AsString().PrimaryKey() + .WithColumn("WorkflowInstanceId").AsString().NotNullable().Indexed() + .WithColumn("ActivityId").AsString().NotNullable().Indexed() + .WithColumn("ActivityNodeId").AsString(MaxNodeIdColumnLength).NotNullable().Indexed() + .WithColumn("ActivityType").AsString().NotNullable().Indexed() + .WithColumn("ActivityTypeVersion").AsInt32().NotNullable().Indexed() + .WithColumn("ActivityName").AsString().Nullable().Indexed() + .WithColumn("SerializedActivityState").AsString(MaxValue).Nullable() + .WithColumn("SerializedPayload").AsString(MaxValue).Nullable() + .WithColumn("SerializedOutputs").AsString(MaxValue).Nullable() + .WithColumn("SerializedException").AsString(MaxValue).Nullable() + .WithColumn("StartedAt").AsDateTime2().NotNullable().Indexed() + .WithColumn("CompletedAt").AsDateTime2().Nullable().Indexed() + .WithColumn("HasBookmarks").AsBoolean().NotNullable().Indexed() + .WithColumn("Status").AsString().NotNullable().Indexed() + ; + } + + if (!Schema.Table("WorkflowInboxMessages").Exists()) + { + IfDatabase(MigrationDatabases.IsDateTimeOffsetProvider) + .Create + .Table("WorkflowInboxMessages") + .WithColumn("Id").AsString().PrimaryKey() + .WithColumn("ActivityTypeName").AsString().NotNullable().Indexed() + .WithColumn("WorkflowInstanceId").AsString().Nullable().Indexed() + .WithColumn("ActivityInstanceId").AsString().Nullable().Indexed() + .WithColumn("CorrelationId").AsString().Nullable().Indexed() + .WithColumn("Hash").AsString().NotNullable().Indexed() + .WithColumn("SerializedBookmarkPayload").AsString(MaxValue) + .WithColumn("SerializedInput").AsString(MaxValue).Nullable() + .WithColumn("CreatedAt").AsDateTimeOffset().Indexed() + .WithColumn("ExpiresAt").AsDateTimeOffset().Indexed() + ; + + IfDatabase("Sqlite") + .Create + .Table("WorkflowInboxMessages") + .WithColumn("Id").AsString().PrimaryKey() + .WithColumn("ActivityTypeName").AsString().NotNullable().Indexed() + .WithColumn("WorkflowInstanceId").AsString().Nullable().Indexed() + .WithColumn("ActivityInstanceId").AsString().Nullable().Indexed() + .WithColumn("CorrelationId").AsString().Nullable().Indexed() + .WithColumn("Hash").AsString().NotNullable().Indexed() + .WithColumn("SerializedBookmarkPayload").AsString(MaxValue) + .WithColumn("SerializedInput").AsString(MaxValue).Nullable() + .WithColumn("CreatedAt").AsDateTime2().Indexed() + .WithColumn("ExpiresAt").AsDateTime2().Indexed() + ; + } } /// @@ -180,4 +195,4 @@ public override void Down() Delete.Table("ActivityExecutionRecords"); Delete.Table("WorkflowInboxMessages"); } -} \ No newline at end of file +} diff --git a/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/V3_3.cs b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/V3_3.cs index 84e50e760c..3112dd564d 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/V3_3.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/V3_3.cs @@ -21,33 +21,36 @@ public override void Up() Alter.Table("ActivityExecutionRecords").AddColumn("SerializedProperties").AsString(MaxValue).Nullable(); Alter.Table("KeyValuePairs").AddColumn("TenantId").AsString().Nullable(); - IfDatabase("SqlServer", "Oracle", "MySql", "Postgres", "PostgreSQL") - .Create - .Table("BookmarkQueueItems") - .WithColumn("Id").AsString().PrimaryKey() - .WithColumn("TenantId").AsString().Nullable() - .WithColumn("WorkflowInstanceId").AsString().Nullable() - .WithColumn("CorrelationId").AsString().Nullable() - .WithColumn("BookmarkId").AsString().Nullable() - .WithColumn("StimulusHash").AsString().Nullable() - .WithColumn("ActivityInstanceId").AsString().Nullable() - .WithColumn("ActivityTypeName").AsString().Nullable() - .WithColumn("CreatedAt").AsDateTimeOffset().NotNullable() - ; + if (!Schema.Table("BookmarkQueueItems").Exists()) + { + IfDatabase(MigrationDatabases.IsDateTimeOffsetProvider) + .Create + .Table("BookmarkQueueItems") + .WithColumn("Id").AsString().PrimaryKey() + .WithColumn("TenantId").AsString().Nullable() + .WithColumn("WorkflowInstanceId").AsString().Nullable() + .WithColumn("CorrelationId").AsString().Nullable() + .WithColumn("BookmarkId").AsString().Nullable() + .WithColumn("StimulusHash").AsString().Nullable() + .WithColumn("ActivityInstanceId").AsString().Nullable() + .WithColumn("ActivityTypeName").AsString().Nullable() + .WithColumn("CreatedAt").AsDateTimeOffset().NotNullable() + ; - IfDatabase("Sqlite") - .Create - .Table("BookmarkQueueItems") - .WithColumn("Id").AsString().PrimaryKey() - .WithColumn("TenantId").AsString().Nullable() - .WithColumn("WorkflowInstanceId").AsString().Nullable() - .WithColumn("CorrelationId").AsString().Nullable() - .WithColumn("BookmarkId").AsString().Nullable() - .WithColumn("StimulusHash").AsString().Nullable() - .WithColumn("ActivityInstanceId").AsString().Nullable() - .WithColumn("ActivityTypeName").AsString().Nullable() - .WithColumn("CreatedAt").AsDateTime2().NotNullable() - ; + IfDatabase("Sqlite") + .Create + .Table("BookmarkQueueItems") + .WithColumn("Id").AsString().PrimaryKey() + .WithColumn("TenantId").AsString().Nullable() + .WithColumn("WorkflowInstanceId").AsString().Nullable() + .WithColumn("CorrelationId").AsString().Nullable() + .WithColumn("BookmarkId").AsString().Nullable() + .WithColumn("StimulusHash").AsString().Nullable() + .WithColumn("ActivityInstanceId").AsString().Nullable() + .WithColumn("ActivityTypeName").AsString().Nullable() + .WithColumn("CreatedAt").AsDateTime2().NotNullable() + ; + } } /// diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs index 860a43cdd0..8a550bd17c 100644 --- a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs @@ -12,7 +12,7 @@ namespace Elsa.Persistence.Dapper.UnitTests; /// /// Runs the Dapper FluentMigrator assembly against a fresh PostgreSQL database. -/// Covers the FluentMigrator 7.2 IfDatabase("Postgres") vs "PostgreSQL" mismatch from issue #255. +/// Covers the FluentMigrator 7.2 IfDatabase("Postgres") vs PostgreSQL* mismatch from issue #255. /// public sealed class DapperPostgreSqlMigrationTests : IAsyncLifetime { diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgresProcessorNameTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgresProcessorNameTests.cs index 8530c4056d..d2e41770e1 100644 --- a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgresProcessorNameTests.cs +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgresProcessorNameTests.cs @@ -1,3 +1,4 @@ +using Elsa.Persistence.Dapper.Migrations; using FluentMigrator; using FluentMigrator.Runner; using Microsoft.Extensions.DependencyInjection; @@ -5,9 +6,7 @@ namespace Elsa.Persistence.Dapper.UnitTests; /// -/// Documents FluentMigrator 7.2's AddPostgres() processor identifiers. -/// The Dapper IfDatabase lists must include both "Postgres" and "PostgreSQL" -/// because 7.2 reports the latter (and "PostgreSQL15_0") but not the former. +/// Documents FluentMigrator 7.2 processor identifiers and the Dapper IfDatabase match. /// public sealed class DapperPostgresProcessorNameTests { @@ -33,8 +32,36 @@ public void AddPostgres_Processor_ReportsPostgreSQL_NotPostgres() Assert.DoesNotContain("Postgres", names); var legacyGuard = new[] { "SqlServer", "Oracle", "MySql", "Postgres" }; - var fixedGuard = new[] { "SqlServer", "Oracle", "MySql", "Postgres", "PostgreSQL" }; Assert.False(legacyGuard.Any(names.Contains), "IfDatabase(\"Postgres\") must not match FluentMigrator 7.2 AddPostgres()."); - Assert.True(fixedGuard.Any(names.Contains), "IfDatabase(..., \"PostgreSQL\") must match FluentMigrator 7.2 AddPostgres()."); + Assert.True(MigrationDatabases.IsDateTimeOffsetProvider(processor.DatabaseType)); + } + + [Theory] + [InlineData("Postgres")] + [InlineData("PostgreSQL")] + [InlineData("PostgreSQL10_0")] + [InlineData("PostgreSQL11_0")] + [InlineData("PostgreSQL15_0")] + [InlineData("Postgres92")] + [InlineData("PostgreSQL92")] + [InlineData("postgres")] + [InlineData("postgresql15_0")] + [InlineData("SqlServer")] + [InlineData("Oracle")] + [InlineData("MySql")] + public void DateTimeOffsetProvider_MatchesSqlServerOracleMySql_AndEveryFluentMigrator72PostgresName(string databaseType) + { + Assert.True(MigrationDatabases.IsDateTimeOffsetProvider(databaseType)); + } + + [Theory] + [InlineData("Sqlite")] + [InlineData("SQLite")] + [InlineData("SqlServer2008")] + [InlineData("MySql8")] + [InlineData("Oracle12c")] + public void DateTimeOffsetProvider_DoesNotChangeOtherProviders(string databaseType) + { + Assert.False(MigrationDatabases.IsDateTimeOffsetProvider(databaseType)); } } diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperRuntimeMigrationTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperRuntimeMigrationTests.cs index 34bbf45381..75c5dc8a31 100644 --- a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperRuntimeMigrationTests.cs +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperRuntimeMigrationTests.cs @@ -80,6 +80,22 @@ public void UpgradeFromV3_5_AppliesV3_7WithoutDuplicateAggregateFaultCount() Assert.True(TableExists("Roles")); } + [Fact(DisplayName = "Replaying 10001/20001 when tables already exist does not fail (no VersionInfo rows)")] + public void ReplayingCreateTableMigrations_WhenTablesAlreadyExist_DoesNotFail() + { + MigrateUp(); + using (var connection = new SqliteConnection(_connectionString)) + connection.Execute("DELETE FROM VersionInfo WHERE Version IN (10001, 20001)"); + + MigrateUp(); + + Assert.True(TableExists("WorkflowDefinitions")); + Assert.True(TableExists("WorkflowInstances")); + Assert.True(TableExists("Bookmarks")); + Assert.True(TableExists("ActivityExecutionRecords")); + Assert.True(TableExists("Roles")); + } + [Fact(DisplayName = "Rolling back V3_7 leaves V3_5's AggregateFaultCount so V3_5.Down() does not double-drop")] public void RollingBackThroughV3_7AndV3_5_DoesNotDoubleDropAggregateFaultCount() { From 86b69748b49ed0b1a456f65654803ee78684aa9f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 28 Sep 2026 07:32:40 +0000 Subject: [PATCH 047/169] chore(deps): bump ElsaVersion to 3.9.0-preview.5724 Pin extensions to the core preview that added IWorkflowInstanceStore.TryMarkInterruptedAsync so Dapper/Mongo/Elastic stores compile as interface implementations. Co-authored-by: Sipke Schoorstra --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index fdb2ac0813..f1159e5143 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -37,7 +37,7 @@ - 3.9.0-preview.5708 + 3.9.0-preview.5724 3.8.4 From f3631ec0105eafe31a2240a12cb4625b1f7eb6b5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 28 Sep 2026 07:33:52 +0000 Subject: [PATCH 048/169] fix(dapper): restore alias-aware IfDatabase list and narrow PG persist test The predicate overload only sees DatabaseType, so AddSqlServer() (SqlServer2016), AddMySql() (MySql8) and AddOracleManaged() no longer matched. Switch back to IfDatabase(params string[]) with a shared DateTimeOffsetProviders list: SqlServer, Oracle, MySql, Postgres, PostgreSQL, PostgreSQL92. Assert the real processors from AddSqlServer/AddSQLite/AddPostgres/ AddMySql/AddOracleManaged take exactly one create branch. The fresh-PG Testcontainers test now asserts migrate + tables only. Quoting identifiers plus including the PK in PostgreSqlDialect.Upsert is a store-wide dialect change, not a focused migration fix. Co-authored-by: Sipke Schoorstra --- Directory.Packages.props | 2 + .../Management/Initial.cs | 4 +- .../MigrationDatabases.cs | 45 ++++--- .../Runtime/Initial.cs | 8 +- .../Runtime/V3_3.cs | 2 +- .../DapperPostgreSqlMigrationTests.cs | 60 +-------- .../DapperPostgresProcessorNameTests.cs | 117 +++++++++++++----- .../Elsa.Persistence.Dapper.UnitTests.csproj | 3 + 8 files changed, 126 insertions(+), 115 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index d3d59ee557..2510df9dff 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -164,6 +164,8 @@ + + diff --git a/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Management/Initial.cs b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Management/Initial.cs index 1cff978ea2..0ca45c9662 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Management/Initial.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Management/Initial.cs @@ -14,7 +14,7 @@ public override void Up() { if (!Schema.Table("WorkflowDefinitions").Exists()) { - IfDatabase(MigrationDatabases.IsDateTimeOffsetProvider) + IfDatabase(MigrationDatabases.DateTimeOffsetProviders) .Create .Table("WorkflowDefinitions") .WithColumn("Id").AsString().PrimaryKey() @@ -59,7 +59,7 @@ public override void Up() if (!Schema.Table("WorkflowInstances").Exists()) { - IfDatabase(MigrationDatabases.IsDateTimeOffsetProvider) + IfDatabase(MigrationDatabases.DateTimeOffsetProviders) .Create .Table("WorkflowInstances") .WithColumn("Id").AsString().PrimaryKey() diff --git a/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/MigrationDatabases.cs b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/MigrationDatabases.cs index b6461c4e08..b0a5e00428 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/MigrationDatabases.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/MigrationDatabases.cs @@ -1,28 +1,35 @@ namespace Elsa.Persistence.Dapper.Migrations; /// -/// Provider matching for Dapper IfDatabase create-table branches. +/// Provider names passed to FluentMigrator's alias-aware IfDatabase(params string[]) overload. /// /// -/// -/// IfDatabase(params string[]) is an exact -/// match against the processor's DatabaseType and DatabaseTypeAliases. -/// It is not a prefix match. -/// -/// -/// IfDatabase(Predicate<string>) receives only DatabaseType (aliases are ignored). -/// FluentMigrator 7.2 postgres processors all report a DatabaseType that starts with -/// Postgres: Postgres, PostgreSQL, PostgreSQL10_0, PostgreSQL11_0, -/// PostgreSQL15_0, Postgres92, PostgreSQL92. A case-insensitive prefix check -/// therefore matches every 7.2 postgres processor, including AddPostgres()'s -/// PostgreSQL15_0, without relying on aliases. -/// +/// IfDatabase(params string[]) does an exact +/// match against the processor's DatabaseType or DatabaseTypeAliases. +/// It is not a prefix match. The predicate overload sees only DatabaseType and must not +/// be used here: FluentMigrator 7.2 reports SqlServer2016, MySql8, +/// OracleManaged, PostgreSQL15_0, etc. as DatabaseType, with +/// SqlServer / MySql / Oracle / PostgreSQL as aliases. /// internal static class MigrationDatabases { - public static bool IsDateTimeOffsetProvider(string databaseType) => - databaseType.Equals("SqlServer", StringComparison.OrdinalIgnoreCase) - || databaseType.Equals("Oracle", StringComparison.OrdinalIgnoreCase) - || databaseType.Equals("MySql", StringComparison.OrdinalIgnoreCase) - || databaseType.StartsWith("Postgres", StringComparison.OrdinalIgnoreCase); + /// + /// Names matched against DatabaseType or aliases for the DateTimeOffset create-table branch + /// (everything except SQLite). + /// + /// + /// Postgres covers the pre-7.2 PostgresProcessor. + /// PostgreSQL is an alias on FluentMigrator 7.2 AddPostgres() / + /// AddPostgres10_0() / AddPostgres11_0() / AddPostgres15_0(). + /// PostgreSQL92 is an alias on AddPostgres92() (DatabaseType Postgres92). + /// + public static readonly string[] DateTimeOffsetProviders = + [ + "SqlServer", + "Oracle", + "MySql", + "Postgres", + "PostgreSQL", + "PostgreSQL92" + ]; } diff --git a/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/Initial.cs b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/Initial.cs index e2e43d0d41..7515ba24f7 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/Initial.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/Initial.cs @@ -28,7 +28,7 @@ public override void Up() if (!Schema.Table("Bookmarks").Exists()) { - IfDatabase(MigrationDatabases.IsDateTimeOffsetProvider) + IfDatabase(MigrationDatabases.DateTimeOffsetProviders) .Create .Table("Bookmarks") .WithColumn("Id").AsString().PrimaryKey() @@ -57,7 +57,7 @@ public override void Up() if (!Schema.Table("WorkflowExecutionLogRecords").Exists()) { - IfDatabase(MigrationDatabases.IsDateTimeOffsetProvider) + IfDatabase(MigrationDatabases.DateTimeOffsetProviders) .Create .Table("WorkflowExecutionLogRecords") .WithColumn("Id").AsString().PrimaryKey() @@ -111,7 +111,7 @@ public override void Up() if (!Schema.Table("ActivityExecutionRecords").Exists()) { - IfDatabase(MigrationDatabases.IsDateTimeOffsetProvider) + IfDatabase(MigrationDatabases.DateTimeOffsetProviders) .Create .Table("ActivityExecutionRecords") .WithColumn("Id").AsString().PrimaryKey() @@ -154,7 +154,7 @@ public override void Up() if (!Schema.Table("WorkflowInboxMessages").Exists()) { - IfDatabase(MigrationDatabases.IsDateTimeOffsetProvider) + IfDatabase(MigrationDatabases.DateTimeOffsetProviders) .Create .Table("WorkflowInboxMessages") .WithColumn("Id").AsString().PrimaryKey() diff --git a/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/V3_3.cs b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/V3_3.cs index 3112dd564d..58af18ad6c 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/V3_3.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/V3_3.cs @@ -23,7 +23,7 @@ public override void Up() if (!Schema.Table("BookmarkQueueItems").Exists()) { - IfDatabase(MigrationDatabases.IsDateTimeOffsetProvider) + IfDatabase(MigrationDatabases.DateTimeOffsetProviders) .Create .Table("BookmarkQueueItems") .WithColumn("Id").AsString().PrimaryKey() diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs index 8a550bd17c..d5da059889 100644 --- a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs @@ -1,8 +1,4 @@ using Dapper; -using Elsa.Common.Multitenancy; -using Elsa.Persistence.Dapper.Extensions; -using Elsa.Persistence.Dapper.Modules.Runtime.Records; -using Elsa.Persistence.Dapper.Services; using FluentMigrator.Runner; using Microsoft.Extensions.DependencyInjection; using Npgsql; @@ -13,6 +9,9 @@ namespace Elsa.Persistence.Dapper.UnitTests; /// /// Runs the Dapper FluentMigrator assembly against a fresh PostgreSQL database. /// Covers the FluentMigrator 7.2 IfDatabase("Postgres") vs PostgreSQL* mismatch from issue #255. +/// Persist is not asserted here: the Dapper PostgreSQL dialect emits unquoted identifiers +/// while FluentMigrator force-quotes table/column names, and PostgreSqlDialect.Upsert +/// omits the primary key from the insert list. /// public sealed class DapperPostgreSqlMigrationTests : IAsyncLifetime { @@ -44,33 +43,11 @@ public async Task DisposeAsync() await _container.DisposeAsync(); } - [Fact(DisplayName = "A fresh PostgreSQL DB applies every Dapper migration and can persist SerializedMetadata + AggregateFaultCount")] - public async Task FreshPostgreSqlDatabase_MigratesAndPersistsActivityExecutionRecord() + [Fact(DisplayName = "A fresh PostgreSQL DB applies every Dapper migration and creates the expected tables")] + public void FreshPostgreSqlDatabase_MigratesAndCreatesExpectedTables() { - // Arrange - var store = CreateActivityExecutionStore(); - var record = new ActivityExecutionRecordRecord - { - Id = "rec-1", - WorkflowInstanceId = "wf-1", - ActivityId = "act-1", - ActivityNodeId = "node-1", - ActivityType = "Elsa.WriteLine", - ActivityTypeVersion = 1, - ActivityName = "WriteLine", - StartedAt = DateTimeOffset.UtcNow, - HasBookmarks = false, - Status = "Finished", - SerializedMetadata = """{"source":"fresh-db"}""", - AggregateFaultCount = 2 - }; - - // Act MigrateUp(); - await store.SaveAsync(record); - var loaded = await store.FindAsync(q => q.Is(nameof(ActivityExecutionRecordRecord.Id), record.Id), cancellationToken: CancellationToken.None); - // Assert var columns = ActivityExecutionRecordColumns(); Assert.Contains(columns, name => name.Equals("AggregateFaultCount", StringComparison.OrdinalIgnoreCase)); Assert.Contains(columns, name => name.Equals("SerializedMetadata", StringComparison.OrdinalIgnoreCase)); @@ -80,15 +57,6 @@ public async Task FreshPostgreSqlDatabase_MigratesAndPersistsActivityExecutionRe Assert.True(TableExists("Bookmarks")); Assert.True(TableExists("ActivityExecutionRecords")); Assert.True(TableExists("BookmarkQueueItems")); - Assert.NotNull(loaded); - Assert.Equal(2, loaded.AggregateFaultCount); - Assert.Equal("""{"source":"fresh-db"}""", loaded.SerializedMetadata); - } - - private Store CreateActivityExecutionStore() - { - var connectionProvider = new PostgreSqlDbConnectionProvider(_connectionString); - return new Store(connectionProvider, new TestTenantAccessor(), "ActivityExecutionRecords"); } private void MigrateUp() @@ -130,22 +98,4 @@ FROM information_schema.tables """, new { tableName }) == 1; } - - private sealed class TestTenantAccessor : ITenantAccessor - { - public string TenantId => Tenant?.Id ?? Tenant.DefaultTenantId; - public Tenant? Tenant { get; private set; } - - public IDisposable PushContext(Tenant? tenant) - { - var previousTenant = Tenant; - Tenant = tenant; - return new Restore(() => Tenant = previousTenant); - } - - private sealed class Restore(Action restore) : IDisposable - { - public void Dispose() => restore(); - } - } } diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgresProcessorNameTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgresProcessorNameTests.cs index d2e41770e1..e7d0b74392 100644 --- a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgresProcessorNameTests.cs +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgresProcessorNameTests.cs @@ -6,62 +6,111 @@ namespace Elsa.Persistence.Dapper.UnitTests; /// -/// Documents FluentMigrator 7.2 processor identifiers and the Dapper IfDatabase match. +/// Documents FluentMigrator 7.2 processor identifiers and the alias-aware IfDatabase match. /// public sealed class DapperPostgresProcessorNameTests { + private static readonly string[] SqliteProviders = ["Sqlite"]; + [Fact(DisplayName = "FluentMigrator 7.2 AddPostgres() reports PostgreSQL15_0 / PostgreSQL, not Postgres")] public void AddPostgres_Processor_ReportsPostgreSQL_NotPostgres() { - using var services = new ServiceCollection() - .AddFluentMigratorCore() - .ConfigureRunner(rb => rb - .AddPostgres() - .WithGlobalConnectionString("Host=localhost;Database=unused")) - .BuildServiceProvider(false); - using var scope = services.CreateScope(); - var processor = scope.ServiceProvider.GetRequiredService(); - - var names = new HashSet(processor.DatabaseTypeAliases, StringComparer.OrdinalIgnoreCase) - { - processor.DatabaseType - }; + var processor = ResolveIds(rb => rb.AddPostgres()); Assert.Equal("PostgreSQL15_0", processor.DatabaseType); - Assert.Contains("PostgreSQL", names); - Assert.DoesNotContain("Postgres", names); + Assert.Contains("PostgreSQL", processor.Names); + Assert.DoesNotContain("Postgres", processor.Names); var legacyGuard = new[] { "SqlServer", "Oracle", "MySql", "Postgres" }; - Assert.False(legacyGuard.Any(names.Contains), "IfDatabase(\"Postgres\") must not match FluentMigrator 7.2 AddPostgres()."); - Assert.True(MigrationDatabases.IsDateTimeOffsetProvider(processor.DatabaseType)); + Assert.False(legacyGuard.Any(processor.Names.Contains), "IfDatabase(\"Postgres\") must not match FluentMigrator 7.2 AddPostgres()."); + Assert.True(Matches(MigrationDatabases.DateTimeOffsetProviders, processor)); + } + + [Fact(DisplayName = "AddSqlServer() takes the DateTimeOffset IfDatabase branch via the SqlServer alias")] + public void AddSqlServer_TakesDateTimeOffsetBranch() => + AssertExactlyOneCreateBranch(ResolveIds(rb => rb.AddSqlServer()), dateTimeOffset: true); + + [Fact(DisplayName = "AddSQLite() takes the Sqlite IfDatabase branch")] + public void AddSqlite_TakesSqliteBranch() => + AssertExactlyOneCreateBranch(ResolveIds(rb => rb.AddSQLite()), dateTimeOffset: false); + + [Fact(DisplayName = "AddPostgres() takes the DateTimeOffset IfDatabase branch via the PostgreSQL alias")] + public void AddPostgres_TakesDateTimeOffsetBranch() => + AssertExactlyOneCreateBranch(ResolveIds(rb => rb.AddPostgres()), dateTimeOffset: true); + + [Fact(DisplayName = "AddMySql() takes the DateTimeOffset IfDatabase branch via the MySql alias")] + public void AddMySql_TakesDateTimeOffsetBranch() => + AssertExactlyOneCreateBranch(ResolveIds(rb => rb.AddMySql()), dateTimeOffset: true); + + [Fact(DisplayName = "AddOracleManaged() takes the DateTimeOffset IfDatabase branch via the Oracle alias")] + public void AddOracleManaged_TakesDateTimeOffsetBranch() => + AssertExactlyOneCreateBranch(ResolveIds(rb => rb.AddOracleManaged()), dateTimeOffset: true); + + [Fact] + public void DateTimeOffsetProviders_MatchSqlServer2016ThroughAlias_NotDatabaseTypeAlone() + { + // AddSqlServer() reports DatabaseType SqlServer2016 with alias SqlServer. + // The params overload matches aliases; a DatabaseType-only predicate would miss it. + Assert.DoesNotContain("SqlServer2016", MigrationDatabases.DateTimeOffsetProviders); + Assert.True(Matches(MigrationDatabases.DateTimeOffsetProviders, "SqlServer2016", ["SqlServer"])); + Assert.False(Matches(MigrationDatabases.DateTimeOffsetProviders, "SqlServer2016", [])); } [Theory] [InlineData("Postgres")] [InlineData("PostgreSQL")] - [InlineData("PostgreSQL10_0")] - [InlineData("PostgreSQL11_0")] - [InlineData("PostgreSQL15_0")] - [InlineData("Postgres92")] [InlineData("PostgreSQL92")] - [InlineData("postgres")] - [InlineData("postgresql15_0")] [InlineData("SqlServer")] [InlineData("Oracle")] [InlineData("MySql")] - public void DateTimeOffsetProvider_MatchesSqlServerOracleMySql_AndEveryFluentMigrator72PostgresName(string databaseType) + public void DateTimeOffsetProviders_ContainsCanonicalNames(string name) { - Assert.True(MigrationDatabases.IsDateTimeOffsetProvider(databaseType)); + Assert.Contains(name, MigrationDatabases.DateTimeOffsetProviders); } - [Theory] - [InlineData("Sqlite")] - [InlineData("SQLite")] - [InlineData("SqlServer2008")] - [InlineData("MySql8")] - [InlineData("Oracle12c")] - public void DateTimeOffsetProvider_DoesNotChangeOtherProviders(string databaseType) + [Fact] + public void DateTimeOffsetProviders_DoesNotContainSqlite() + { + Assert.DoesNotContain("Sqlite", MigrationDatabases.DateTimeOffsetProviders, StringComparer.OrdinalIgnoreCase); + Assert.DoesNotContain("SQLite", MigrationDatabases.DateTimeOffsetProviders, StringComparer.OrdinalIgnoreCase); + } + + private static void AssertExactlyOneCreateBranch(ProcessorIds processor, bool dateTimeOffset) + { + var dateTimeOffsetApplies = Matches(MigrationDatabases.DateTimeOffsetProviders, processor); + var sqliteApplies = Matches(SqliteProviders, processor); + Assert.NotEqual(dateTimeOffsetApplies, sqliteApplies); + Assert.Equal(dateTimeOffset, dateTimeOffsetApplies); + } + + private static bool Matches(IReadOnlyCollection guard, ProcessorIds processor) => + Matches(guard, processor.DatabaseType, processor.Aliases); + + private static bool Matches(IReadOnlyCollection guard, string databaseType, IEnumerable aliases) + { + // Same semantics as FluentMigrator 7.2 IfDatabase(params string[]): + // exact OrdinalIgnoreCase against DatabaseType ∪ DatabaseTypeAliases. + var names = new HashSet(aliases, StringComparer.OrdinalIgnoreCase) { databaseType }; + return guard.Any(names.Contains); + } + + private static ProcessorIds ResolveIds(Action addProvider) + { + using var services = new ServiceCollection() + .AddFluentMigratorCore() + .ConfigureRunner(rb => + { + addProvider(rb); + rb.WithGlobalConnectionString("Host=localhost;Database=unused"); + }) + .BuildServiceProvider(false); + using var scope = services.CreateScope(); + var processor = scope.ServiceProvider.GetRequiredService(); + return new ProcessorIds(processor.DatabaseType, processor.DatabaseTypeAliases.ToArray()); + } + + private sealed record ProcessorIds(string DatabaseType, string[] Aliases) { - Assert.False(MigrationDatabases.IsDateTimeOffsetProvider(databaseType)); + public HashSet Names { get; } = new(Aliases, StringComparer.OrdinalIgnoreCase) { DatabaseType }; } } diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/Elsa.Persistence.Dapper.UnitTests.csproj b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/Elsa.Persistence.Dapper.UnitTests.csproj index b252f6b95d..6b1868be85 100644 --- a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/Elsa.Persistence.Dapper.UnitTests.csproj +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/Elsa.Persistence.Dapper.UnitTests.csproj @@ -1,7 +1,10 @@  + + + From cfbbaada6eee296378bf18f8be911cb3207be2eb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 28 Sep 2026 07:39:41 +0000 Subject: [PATCH 049/169] chore(deps): bump ElsaStudioVersion to 3.9.0-preview.1757 Studio 1757 dropped Blazored.FluentValidation from Elsa.Studio.Core, so Agents/Secrets/WorkflowContexts now reference it directly (2.2.0). Co-authored-by: Sipke Schoorstra --- Directory.Build.props | 2 +- Directory.Packages.props | 1 + .../agents/Elsa.Studio.Agents/Elsa.Studio.Agents.csproj | 4 ++++ .../secrets/Elsa.Studio.Secrets/Elsa.Studio.Secrets.csproj | 4 ++++ .../Elsa.Studio.WorkflowContexts.csproj | 4 ++++ 5 files changed, 14 insertions(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index f1159e5143..c411dab685 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -38,7 +38,7 @@ 3.9.0-preview.5724 - 3.8.4 + 3.9.0-preview.1757 diff --git a/Directory.Packages.props b/Directory.Packages.props index 80cc818159..3d241154a7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -137,6 +137,7 @@ + diff --git a/src/modules/agents/Elsa.Studio.Agents/Elsa.Studio.Agents.csproj b/src/modules/agents/Elsa.Studio.Agents/Elsa.Studio.Agents.csproj index b980ed9983..0b272bd024 100644 --- a/src/modules/agents/Elsa.Studio.Agents/Elsa.Studio.Agents.csproj +++ b/src/modules/agents/Elsa.Studio.Agents/Elsa.Studio.Agents.csproj @@ -13,6 +13,10 @@ + + + + diff --git a/src/modules/secrets/Elsa.Studio.Secrets/Elsa.Studio.Secrets.csproj b/src/modules/secrets/Elsa.Studio.Secrets/Elsa.Studio.Secrets.csproj index bc4672e08e..6499553871 100644 --- a/src/modules/secrets/Elsa.Studio.Secrets/Elsa.Studio.Secrets.csproj +++ b/src/modules/secrets/Elsa.Studio.Secrets/Elsa.Studio.Secrets.csproj @@ -19,6 +19,10 @@ + + + + diff --git a/src/modules/workflows/Elsa.Studio.WorkflowContexts/Elsa.Studio.WorkflowContexts.csproj b/src/modules/workflows/Elsa.Studio.WorkflowContexts/Elsa.Studio.WorkflowContexts.csproj index 904eae849f..f7afa82643 100644 --- a/src/modules/workflows/Elsa.Studio.WorkflowContexts/Elsa.Studio.WorkflowContexts.csproj +++ b/src/modules/workflows/Elsa.Studio.WorkflowContexts/Elsa.Studio.WorkflowContexts.csproj @@ -13,4 +13,8 @@ + + + + From 9eadc4621d3a8df09697b77ea11e457b1c9d9f14 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 28 Sep 2026 07:44:12 +0000 Subject: [PATCH 050/169] fix(dapper): quote PostgreSQL dialect identifiers and include upsert PK FluentMigrator force-quotes PG table/column names. Re-implement ISqlDialect on PostgreSqlDialect so Dapper emits matching quoted identifiers, and include the primary key in ON CONFLICT upsert. Restore the fresh-PG Testcontainers test to migrate, persist, and run a WriteLine workflow to completion. Co-authored-by: Sipke Schoorstra --- .../Dialects/PostgreSqlDialect.cs | 127 ++++++++++++++-- .../DapperPostgreSqlMigrationTests.cs | 143 +++++++++++++++++- .../PostgreSqlDialectTests.cs | 59 ++++++++ 3 files changed, 313 insertions(+), 16 deletions(-) create mode 100644 test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Dialects/PostgreSqlDialect.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Dialects/PostgreSqlDialect.cs index fcd37df54c..97d877f7a5 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Dialects/PostgreSqlDialect.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Dialects/PostgreSqlDialect.cs @@ -1,20 +1,129 @@ +using Elsa.Common.Entities; using Elsa.Persistence.Dapper.Abstractions; +using Elsa.Persistence.Dapper.Contracts; namespace Elsa.Persistence.Dapper.Dialects; /// -/// Represents a SQL Server dialect. +/// PostgreSQL dialect that emits quoted identifiers to match FluentMigrator's +/// ForceQuote=true table and column names. /// -public class PostgreSqlDialect : SqlDialectBase +/// +/// FluentMigrator 7.2's PostgreSQL processor quotes identifiers, so a table created as +/// ActivityExecutionRecords is stored as "ActivityExecutionRecords". +/// Unquoted SQL folds to lowercase (activityexecutionrecords) and misses those objects +/// (42P01). This dialect re-implements so calls through +/// (the Dapper query builder) are quoted without changing +/// or other providers. +/// +/// +/// Compatibility: a handmade lowercase, unquoted schema (create table activityexecutionrecords) +/// will not match quoted "ActivityExecutionRecords". The supported PG path is the +/// FluentMigrator-created mixed-case schema. Handmade lowercase schemas must be renamed to the +/// quoted mixed-case identifiers (or recreated by the migrations) before using this dialect. +/// +/// +public class PostgreSqlDialect : SqlDialectBase, ISqlDialect { + /// + public override string From(string table) => From(table, "*"); + + /// + public override string From(string table, params string[] fields) + { + var fieldList = string.Join(", ", fields.Select(QuoteField)); + return $"select {fieldList} from {Quote(table)} where 1=1"; + } + + /// + public override string And(string field) => $"and {Quote(field)} = @{field}"; + + /// + public override string AndNot(string field) => $"and not {Quote(field)} = @{field}"; + + /// + public override string And(string field, string[] fieldParamNames) => $"and {Quote(field)} in ({string.Join(", ", fieldParamNames)})"; + + /// + public override string AndNot(string field, string[] fieldParamNames) => $"and {Quote(field)} not in ({string.Join(", ", fieldParamNames)})"; + + /// + public override string OrderBy(string field, OrderDirection direction) + { + var directionString = direction == OrderDirection.Ascending ? "asc" : "desc"; + return $"order by {Quote(field)} {directionString}"; + } + /// public override string Upsert(string table, string primaryKeyField, string[] fields, Func? getParamName = null) { - getParamName ??= x => x; - var fieldList = string.Join(", ", fields); - var fieldParamNames = fields.Select(x => $"@{getParamName(x)}"); - var fieldParamList = string.Join(", ", fieldParamNames); - var updateList = string.Join(", ", fields.Select(x => $"{x} = @{getParamName(x)}")); - return $"insert into {table} ({fieldList}) values ({fieldParamList}) on conflict({primaryKeyField}) do update set {updateList}"; + getParamName ??= static x => x; + var fieldList = string.Join(", ", fields.Select(Quote)); + var fieldParamList = string.Join(", ", fields.Select(x => $"@{getParamName(x)}")); + var updateList = string.Join(", ", fields.Select(x => $"{Quote(x)} = @{getParamName(x)}")); + // Include the PK in the insert list: ON CONFLICT cannot insert a NOT NULL PK that was omitted (23502). + return $"insert into {Quote(table)} ({Quote(primaryKeyField)}, {fieldList}) values (@{getParamName(primaryKeyField)}, {fieldParamList}) on conflict({Quote(primaryKeyField)}) do update set {updateList}"; + } + + string ISqlDialect.Delete(string table) => $"delete from {Quote(table)} where 1=1"; + + string ISqlDialect.Count(string table) => ((ISqlDialect)this).Count("*", table); + + string ISqlDialect.Count(string fieldExpression, string table) => + $"select COUNT({QuoteFieldExpression(fieldExpression)}) from {Quote(table)} where 1=1"; + + string ISqlDialect.IsNull(string field) => $"and {Quote(field)} is null"; + + string ISqlDialect.IsNotNull(string field) => $"and {Quote(field)} is not null"; + + string ISqlDialect.Insert(string table, string[] fields, Func? getParamName) + { + getParamName ??= static x => x; + var fieldList = string.Join(", ", fields.Select(Quote)); + var fieldParamList = string.Join(", ", fields.Select(x => $"@{getParamName(x)}")); + return $"INSERT INTO {Quote(table)} ({fieldList}) VALUES ({fieldParamList});"; + } + + string ISqlDialect.Update(string table, string primaryKeyField, string[] fields, Func? getParamName) + { + getParamName ??= static x => x; + var fieldList = string.Join(", ", fields.Select(x => $"{Quote(x)} = @{getParamName(x)}")); + return $"UPDATE {Quote(table)} SET {fieldList} WHERE {Quote(primaryKeyField)} = @{getParamName(primaryKeyField)};"; + } + + string ISqlDialect.Update(string table, string[] fields, Func? getParamName) + { + getParamName ??= static x => x; + var fieldList = string.Join(", ", fields.Select(x => $"{Quote(x)} = @{getParamName(x)}")); + return $"UPDATE {Quote(table)} SET {fieldList} WHERE 1=1"; + } + + /// + /// Quotes a PostgreSQL identifier. * is left as-is. Already-quoted names are returned unchanged. + /// + internal static string Quote(string identifier) + { + if (string.IsNullOrEmpty(identifier) || identifier == "*") + return identifier; + + if (identifier.Length >= 2 && identifier[0] == '"' && identifier[^1] == '"') + return identifier; + + return $"\"{identifier.Replace("\"", "\"\"")}\""; + } + + private static string QuoteField(string field) => field == "*" ? "*" : Quote(field); + + private static string QuoteFieldExpression(string expression) + { + if (string.IsNullOrWhiteSpace(expression) || expression == "*") + return expression; + + var parts = expression.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 1) + return Quote(parts[0]); + + parts[^1] = Quote(parts[^1]); + return string.Join(' ', parts); } -} \ No newline at end of file +} diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs index d5da059889..d74a3fd1d8 100644 --- a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs @@ -1,17 +1,30 @@ using Dapper; +using Elsa.Common.Multitenancy; +using Elsa.Extensions; +using Elsa.Persistence.Dapper.Extensions; +using Elsa.Persistence.Dapper.Modules.Management.Records; +using Elsa.Persistence.Dapper.Modules.Runtime.Records; +using Elsa.Persistence.Dapper.Services; +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Filters; +using Elsa.Workflows.Options; +using Elsa.Workflows.Runtime; +using Elsa.Workflows.Runtime.Entities; +using Elsa.Workflows.Runtime.Filters; +using Elsa.Workflows.Runtime.Options; using FluentMigrator.Runner; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Npgsql; using Testcontainers.PostgreSql; namespace Elsa.Persistence.Dapper.UnitTests; /// -/// Runs the Dapper FluentMigrator assembly against a fresh PostgreSQL database. -/// Covers the FluentMigrator 7.2 IfDatabase("Postgres") vs PostgreSQL* mismatch from issue #255. -/// Persist is not asserted here: the Dapper PostgreSQL dialect emits unquoted identifiers -/// while FluentMigrator force-quotes table/column names, and PostgreSqlDialect.Upsert -/// omits the primary key from the insert list. +/// Fresh PostgreSQL Testcontainers coverage for issue #255: migrate, persist through +/// the quoted Dapper PG dialect, and run a simple workflow to completion on those stores. /// public sealed class DapperPostgreSqlMigrationTests : IAsyncLifetime { @@ -43,8 +56,8 @@ public async Task DisposeAsync() await _container.DisposeAsync(); } - [Fact(DisplayName = "A fresh PostgreSQL DB applies every Dapper migration and creates the expected tables")] - public void FreshPostgreSqlDatabase_MigratesAndCreatesExpectedTables() + [Fact(DisplayName = "A fresh PostgreSQL DB migrates, persists records, and runs a WriteLine workflow to completion through Dapper stores")] + public async Task FreshPostgreSqlDatabase_MigratesPersistsAndRunsWorkflowToCompletion() { MigrateUp(); @@ -57,6 +70,104 @@ public void FreshPostgreSqlDatabase_MigratesAndCreatesExpectedTables() Assert.True(TableExists("Bookmarks")); Assert.True(TableExists("ActivityExecutionRecords")); Assert.True(TableExists("BookmarkQueueItems")); + + await PersistRecordsThroughDapperStores(); + await RunWriteLineWorkflowToCompletion(); + } + + private async Task PersistRecordsThroughDapperStores() + { + var tenantAccessor = new TestTenantAccessor(); + var connectionProvider = new PostgreSqlDbConnectionProvider(_connectionString); + var activityStore = new Store(connectionProvider, tenantAccessor, "ActivityExecutionRecords"); + var instanceStore = new Store(connectionProvider, tenantAccessor, "WorkflowInstances"); + + var activity = new ActivityExecutionRecordRecord + { + Id = "rec-pg-1", + WorkflowInstanceId = "wf-pg-1", + ActivityId = "act-1", + ActivityNodeId = "node-1", + ActivityType = "Elsa.WriteLine", + ActivityTypeVersion = 1, + ActivityName = "WriteLine", + StartedAt = DateTimeOffset.UtcNow, + HasBookmarks = false, + Status = "Finished", + SerializedMetadata = """{"source":"fresh-pg"}""", + AggregateFaultCount = 2 + }; + var instance = new WorkflowInstanceRecord + { + Id = "wf-pg-1", + DefinitionId = "def-pg-1", + DefinitionVersionId = "def-pg-1:1", + Version = 1, + WorkflowState = "{}", + Status = "Finished", + SubStatus = "Finished", + IncidentCount = 0, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }; + + await activityStore.SaveAsync(activity); + await instanceStore.SaveAsync(instance); + + var loadedActivity = await activityStore.FindAsync(q => q.Is(nameof(ActivityExecutionRecordRecord.Id), activity.Id), cancellationToken: CancellationToken.None); + var loadedInstance = await instanceStore.FindAsync(q => q.Is(nameof(WorkflowInstanceRecord.Id), instance.Id), cancellationToken: CancellationToken.None); + + Assert.NotNull(loadedActivity); + Assert.Equal(2, loadedActivity.AggregateFaultCount); + Assert.Equal("""{"source":"fresh-pg"}""", loadedActivity.SerializedMetadata); + Assert.NotNull(loadedInstance); + Assert.Equal("Finished", loadedInstance.Status); + Assert.Equal("def-pg-1", loadedInstance.DefinitionId); + } + + private async Task RunWriteLineWorkflowToCompletion() + { + using var services = CreateElsaHost(); + using var scope = services.CreateScope(); + var sp = scope.ServiceProvider; + + var workflow = Workflow.FromActivity(new WriteLine("hello from dapper postgres")); + var graph = await sp.GetRequiredService().BuildAsync(workflow); + var instanceId = $"wf-pg-run-{Guid.NewGuid():N}"; +#pragma warning disable CS0618 // IWorkflowHost persists through IWorkflowInstanceStore / IActivityExecutionStore; IWorkflowInvoker does not. + var host = await sp.GetRequiredService() + .CreateAsync(graph, new WorkflowHostOptions { NewWorkflowInstanceId = instanceId }); +#pragma warning restore CS0618 + + await host.RunWorkflowAsync(new RunWorkflowOptions()); + + Assert.Equal(WorkflowStatus.Finished, host.WorkflowState.Status); + Assert.Equal(WorkflowSubStatus.Finished, host.WorkflowState.SubStatus); + + var storedInstance = await sp.GetRequiredService() + .FindAsync(new WorkflowInstanceFilter { Id = instanceId }); + Assert.NotNull(storedInstance); + Assert.Equal(WorkflowStatus.Finished, storedInstance.Status); + + var storedActivities = (await sp.GetRequiredService() + .FindManyAsync(new ActivityExecutionRecordFilter { WorkflowInstanceId = instanceId })).ToList(); + Assert.NotEmpty(storedActivities); + Assert.All(storedActivities, record => Assert.Equal(ActivityStatus.Completed, record.Status)); + } + + private ServiceProvider CreateElsaHost() + { + var services = new ServiceCollection(); + services.AddLogging(); + var module = services.CreateModule(); + module.UseDapper(dapper => + { + dapper.DbConnectionProvider = _ => new PostgreSqlDbConnectionProvider(_connectionString); + }); + module.UseWorkflowManagement(management => management.UseDapper()); + module.UseWorkflowRuntime(runtime => runtime.UseDapper()); + module.Apply(); + return services.BuildServiceProvider(validateScopes: true); } private void MigrateUp() @@ -98,4 +209,22 @@ FROM information_schema.tables """, new { tableName }) == 1; } + + private sealed class TestTenantAccessor : ITenantAccessor + { + public string TenantId => Tenant?.Id ?? Tenant.DefaultTenantId; + public Tenant? Tenant { get; private set; } + + public IDisposable PushContext(Tenant? tenant) + { + var previousTenant = Tenant; + Tenant = tenant; + return new Restore(() => Tenant = previousTenant); + } + + private sealed class Restore(Action restore) : IDisposable + { + public void Dispose() => restore(); + } + } } diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs new file mode 100644 index 0000000000..375759693a --- /dev/null +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs @@ -0,0 +1,59 @@ +using Elsa.Common.Entities; +using Elsa.Persistence.Dapper.Contracts; +using Elsa.Persistence.Dapper.Dialects; + +namespace Elsa.Persistence.Dapper.UnitTests; + +/// +/// SQL-shape tests for the PostgreSQL dialect. No database required. +/// +public sealed class PostgreSqlDialectTests +{ + private readonly ISqlDialect _dialect = new PostgreSqlDialect(); + + [Fact(DisplayName = "From quotes the FluentMigrator mixed-case table name")] + public void From_QuotesTableAndStar() + { + Assert.Equal("select * from \"ActivityExecutionRecords\" where 1=1", _dialect.From("ActivityExecutionRecords")); + } + + [Fact(DisplayName = "From quotes each selected field")] + public void From_QuotesFields() + { + Assert.Equal( + "select \"Id\", \"Status\" from \"WorkflowInstances\" where 1=1", + _dialect.From("WorkflowInstances", "Id", "Status")); + } + + [Fact(DisplayName = "And / Count / Delete quote identifiers and leave parameter names unquoted")] + public void Filters_QuoteIdentifiersOnly() + { + Assert.Equal("and \"Id\" = @Id", _dialect.And("Id")); + Assert.Equal("and \"Id\" in (@Id0, @Id1)", _dialect.And("Id", ["@Id0", "@Id1"])); + Assert.Equal("and \"CompletedAt\" is null", _dialect.IsNull("CompletedAt")); + Assert.Equal("select COUNT(*) from \"Bookmarks\" where 1=1", _dialect.Count("Bookmarks")); + Assert.Equal("delete from \"Bookmarks\" where 1=1", _dialect.Delete("Bookmarks")); + Assert.Equal("order by \"CreatedAt\" desc", _dialect.OrderBy("CreatedAt", OrderDirection.Descending)); + } + + [Fact(DisplayName = "Upsert includes the primary key and quotes table, PK, and columns")] + public void Upsert_IncludesPrimaryKeyAndQuotes() + { + var sql = _dialect.Upsert("ActivityExecutionRecords", "Id", ["Status", "AggregateFaultCount"]); + + Assert.Contains("insert into \"ActivityExecutionRecords\" (\"Id\", \"Status\", \"AggregateFaultCount\")", sql, StringComparison.Ordinal); + Assert.Contains("values (@Id, @Status, @AggregateFaultCount)", sql, StringComparison.Ordinal); + Assert.Contains("on conflict(\"Id\")", sql, StringComparison.Ordinal); + Assert.Contains("\"Status\" = @Status", sql, StringComparison.Ordinal); + Assert.DoesNotContain("insert into ActivityExecutionRecords", sql, StringComparison.Ordinal); + } + + [Fact] + public void Quote_IsIdempotentAndEscapesEmbeddedQuotes() + { + Assert.Equal("*", PostgreSqlDialect.Quote("*")); + Assert.Equal("\"Id\"", PostgreSqlDialect.Quote("Id")); + Assert.Equal("\"Id\"", PostgreSqlDialect.Quote("\"Id\"")); + Assert.Equal("\"foo\"\"bar\"", PostgreSqlDialect.Quote("foo\"bar")); + } +} From 23fcce25cbd293250ef14909bda6f7b4b7aca3ee Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 28 Sep 2026 07:47:03 +0000 Subject: [PATCH 051/169] fix(dapper): use Concat for PostgreSQL identifier quoting Avoid interpolated-string quote escaping so the helper stays compile-safe. Co-authored-by: Sipke Schoorstra --- .../Elsa.Persistence.Dapper/Dialects/PostgreSqlDialect.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Dialects/PostgreSqlDialect.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Dialects/PostgreSqlDialect.cs index 97d877f7a5..702fe466d7 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Dialects/PostgreSqlDialect.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Dialects/PostgreSqlDialect.cs @@ -109,7 +109,7 @@ internal static string Quote(string identifier) if (identifier.Length >= 2 && identifier[0] == '"' && identifier[^1] == '"') return identifier; - return $"\"{identifier.Replace("\"", "\"\"")}\""; + return string.Concat("\"", identifier.Replace("\"", "\"\""), "\""); } private static string QuoteField(string field) => field == "*" ? "*" : Quote(field); From 9b14229a8308b0fb8d9feb230fbea60324c97bb1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 28 Sep 2026 07:54:58 +0000 Subject: [PATCH 052/169] fix(dapper): map Npgsql DateTime to DateTimeOffset on PostgreSQL SqliteDbConnectionProvider registers a string-only DateTimeOffset handler on SqlMapper. After persist, PG Find failed with InvalidCastException. Register a PG handler that accepts DateTime, DateTimeOffset, and string. Co-authored-by: Sipke Schoorstra --- .../PostgreSqlDbConnectionProvider.cs | 9 ++++++ .../PostgreSql/DateTimeOffsetHandler.cs | 29 +++++++++++++++++++ .../PostgreSqlDialectTests.cs | 12 ++++++++ 3 files changed, 50 insertions(+) create mode 100644 src/modules/persistence/Elsa.Persistence.Dapper/TypeHandlers/PostgreSql/DateTimeOffsetHandler.cs diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Services/PostgreSqlDbConnectionProvider.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Services/PostgreSqlDbConnectionProvider.cs index 39b627234c..16829755b8 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Services/PostgreSqlDbConnectionProvider.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Services/PostgreSqlDbConnectionProvider.cs @@ -1,6 +1,8 @@ using System.Data; +using Dapper; using Elsa.Persistence.Dapper.Contracts; using Elsa.Persistence.Dapper.Dialects; +using Elsa.Persistence.Dapper.TypeHandlers.PostgreSql; using JetBrains.Annotations; using Npgsql; @@ -14,6 +16,13 @@ public class PostgreSqlDbConnectionProvider : IDbConnectionProvider { private readonly string _connectionString = ""; + static PostgreSqlDbConnectionProvider() + { + // Last AddTypeHandler for DateTimeOffset wins. Replace the SQLite string-only + // handler so Npgsql DateTime values can be read back as DateTimeOffset. + SqlMapper.AddTypeHandler(new DateTimeOffsetHandler()); + } + /// /// Initializes a new instance of the class. /// diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/TypeHandlers/PostgreSql/DateTimeOffsetHandler.cs b/src/modules/persistence/Elsa.Persistence.Dapper/TypeHandlers/PostgreSql/DateTimeOffsetHandler.cs new file mode 100644 index 0000000000..3315e29fa0 --- /dev/null +++ b/src/modules/persistence/Elsa.Persistence.Dapper/TypeHandlers/PostgreSql/DateTimeOffsetHandler.cs @@ -0,0 +1,29 @@ +using System.Data; +using Dapper; + +namespace Elsa.Persistence.Dapper.TypeHandlers.PostgreSql; + +/// +/// Maps PostgreSQL timestamptz values to . +/// +/// +/// Npgsql returns . The SQLite handler registered by +/// SqliteDbConnectionProvider assumes a string and throws +/// on that DateTime. This handler accepts +/// DateTime, DateTimeOffset, and string so PG reads work even when the SQLite +/// handler was registered earlier in the process. +/// +internal sealed class DateTimeOffsetHandler : SqlMapper.TypeHandler +{ + public override void SetValue(IDbDataParameter parameter, DateTimeOffset value) => parameter.Value = value; + + public override DateTimeOffset Parse(object value) => value switch + { + DateTimeOffset dto => dto, + DateTime dt => dt.Kind == DateTimeKind.Unspecified + ? new DateTimeOffset(DateTime.SpecifyKind(dt, DateTimeKind.Utc)) + : new DateTimeOffset(dt), + string s => DateTimeOffset.Parse(s), + _ => throw new InvalidCastException($"Cannot convert {value.GetType()} to DateTimeOffset.") + }; +} diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs index 375759693a..4a30001b0e 100644 --- a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs @@ -56,4 +56,16 @@ public void Quote_IsIdempotentAndEscapesEmbeddedQuotes() Assert.Equal("\"Id\"", PostgreSqlDialect.Quote("\"Id\"")); Assert.Equal("\"foo\"\"bar\"", PostgreSqlDialect.Quote("foo\"bar")); } + + [Fact(DisplayName = "PG DateTimeOffset handler accepts DateTime, DateTimeOffset, and string")] + public void DateTimeOffsetHandler_AcceptsNpgsqlAndSqliteShapes() + { + var handler = new Elsa.Persistence.Dapper.TypeHandlers.PostgreSql.DateTimeOffsetHandler(); + var utc = new DateTime(2026, 9, 28, 7, 52, 22, DateTimeKind.Utc); + var dto = new DateTimeOffset(utc); + + Assert.Equal(dto, handler.Parse(dto)); + Assert.Equal(dto, handler.Parse(utc)); + Assert.Equal(dto, handler.Parse("2026-09-28T07:52:22+00:00")); + } } From 39ae028750ee1bedefc47187723ae689214b49dd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 28 Sep 2026 08:01:18 +0000 Subject: [PATCH 053/169] fix(dapper): terminate PostgreSQL upsert so SaveMany can batch IWorkflowHost SaveMany concatenates upserts. Without a trailing semicolon PostgreSQL reports 42601 at the second insert. Co-authored-by: Sipke Schoorstra --- .../Elsa.Persistence.Dapper/Dialects/PostgreSqlDialect.cs | 2 +- .../Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Dialects/PostgreSqlDialect.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Dialects/PostgreSqlDialect.cs index 702fe466d7..4d2178b598 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Dialects/PostgreSqlDialect.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Dialects/PostgreSqlDialect.cs @@ -62,7 +62,7 @@ public override string Upsert(string table, string primaryKeyField, string[] fie var fieldParamList = string.Join(", ", fields.Select(x => $"@{getParamName(x)}")); var updateList = string.Join(", ", fields.Select(x => $"{Quote(x)} = @{getParamName(x)}")); // Include the PK in the insert list: ON CONFLICT cannot insert a NOT NULL PK that was omitted (23502). - return $"insert into {Quote(table)} ({Quote(primaryKeyField)}, {fieldList}) values (@{getParamName(primaryKeyField)}, {fieldParamList}) on conflict({Quote(primaryKeyField)}) do update set {updateList}"; + return $"insert into {Quote(table)} ({Quote(primaryKeyField)}, {fieldList}) values (@{getParamName(primaryKeyField)}, {fieldParamList}) on conflict({Quote(primaryKeyField)}) do update set {updateList};"; } string ISqlDialect.Delete(string table) => $"delete from {Quote(table)} where 1=1"; diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs index 4a30001b0e..3949ab8480 100644 --- a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs @@ -45,6 +45,7 @@ public void Upsert_IncludesPrimaryKeyAndQuotes() Assert.Contains("values (@Id, @Status, @AggregateFaultCount)", sql, StringComparison.Ordinal); Assert.Contains("on conflict(\"Id\")", sql, StringComparison.Ordinal); Assert.Contains("\"Status\" = @Status", sql, StringComparison.Ordinal); + Assert.EndsWith(";", sql.Trim()); Assert.DoesNotContain("insert into ActivityExecutionRecords", sql, StringComparison.Ordinal); } From 14a289db74755d21f4b2875da69b881396d8780c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 28 Sep 2026 08:17:07 +0000 Subject: [PATCH 054/169] fix(dapper): quote shared query-builder identifiers via dialect hook Route every identifier ParameterizedQueryBuilderExtensions inlines through ISqlDialect.QuoteIdentifier. The default is a no-op so SQLite, SQL Server, MySQL and Oracle SQL stays byte-identical; only PostgreSqlDialect double-quotes. Snapshot tests lock the pre-hook non-PG SQL. The fresh-PG Testcontainers test now also covers list, search, OrderBy, VersionOptions and paged delete. Co-authored-by: Sipke Schoorstra --- .../Abstractions/SqlDialectBase.cs | 3 + .../Contracts/ISqlDialect.cs | 10 + .../Dialects/PostgreSqlDialect.cs | 7 +- .../ParameterizedQueryBuilderExtensions.cs | 25 +- .../Stores/DapperWorkflowInstanceStore.cs | 2 +- .../DapperPostgreSqlMigrationTests.cs | 152 +++++++++++ .../NonPgQuerySqlSnapshotTests.cs | 240 ++++++++++++++++++ .../PostgreSqlDialectTests.cs | 13 + 8 files changed, 438 insertions(+), 14 deletions(-) create mode 100644 test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/NonPgQuerySqlSnapshotTests.cs diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Abstractions/SqlDialectBase.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Abstractions/SqlDialectBase.cs index 2c130a477e..870f6ee6fe 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Abstractions/SqlDialectBase.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Abstractions/SqlDialectBase.cs @@ -10,6 +10,9 @@ namespace Elsa.Persistence.Dapper.Abstractions; /// public abstract class SqlDialectBase : ISqlDialect { + /// + public virtual string QuoteIdentifier(string name) => name; + /// public virtual string From(string table) => From(table, "*"); diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Contracts/ISqlDialect.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Contracts/ISqlDialect.cs index 52b5178a9d..1371f5c949 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Contracts/ISqlDialect.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Contracts/ISqlDialect.cs @@ -8,6 +8,16 @@ namespace Elsa.Persistence.Dapper.Contracts; /// public interface ISqlDialect { + /// + /// Quotes a table or column identifier. + /// + /// + /// The default returns unchanged so SQLite, SQL Server, + /// MySQL and Oracle SQL stays byte-identical. PostgreSQL overrides this to + /// double-quote identifiers so they match FluentMigrator ForceQuote=true. + /// + string QuoteIdentifier(string name) => name; + /// /// Returns a SELECT FROM query. /// diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Dialects/PostgreSqlDialect.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Dialects/PostgreSqlDialect.cs index 4d2178b598..07e2b06b89 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Dialects/PostgreSqlDialect.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Dialects/PostgreSqlDialect.cs @@ -13,8 +13,8 @@ namespace Elsa.Persistence.Dapper.Dialects; /// ActivityExecutionRecords is stored as "ActivityExecutionRecords". /// Unquoted SQL folds to lowercase (activityexecutionrecords) and misses those objects /// (42P01). This dialect re-implements so calls through -/// (the Dapper query builder) are quoted without changing -/// or other providers. +/// (the Dapper query builder) are quoted. Shared query-builder + /// inlines go through , whose non-PG default is a no-op. /// /// /// Compatibility: a handmade lowercase, unquoted schema (create table activityexecutionrecords) @@ -25,6 +25,9 @@ namespace Elsa.Persistence.Dapper.Dialects; /// public class PostgreSqlDialect : SqlDialectBase, ISqlDialect { + /// + public override string QuoteIdentifier(string name) => Quote(name); + /// public override string From(string table) => From(table, "*"); diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs index 767b081c53..9795e88910 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs @@ -81,7 +81,7 @@ public static ParameterizedQuery Delete(this ParameterizedQuery query, string ta public static ParameterizedQuery Delete(this ParameterizedQuery query, string table, string primaryKey, ParameterizedQuery innerQuery) { query.Sql.AppendLine(query.Dialect.Delete(table)); - query.Sql.AppendLine($"and {primaryKey} in ("); + query.Sql.AppendLine($"and {query.QuoteIdent(primaryKey)} in ("); query.Sql.AppendLine(innerQuery.Sql.ToString()); query.Sql.AppendLine(")"); return query; @@ -191,7 +191,7 @@ public static ParameterizedQuery WorkflowDefinitionSearchTerm(this Parameterized if (string.IsNullOrWhiteSpace(searchTerm)) return query; var searchTermLike = $"%{searchTerm}%"; - query.Sql.AppendLine("and (Name like @SearchTermLike or Description like @SearchTermLike or Id like @SearchTerm or DefinitionId like @SearchTerm)"); + query.Sql.AppendLine($"and ({query.QuoteIdent("Name")} like @SearchTermLike or {query.QuoteIdent("Description")} like @SearchTermLike or {query.QuoteIdent("Id")} like @SearchTerm or {query.QuoteIdent("DefinitionId")} like @SearchTerm)"); query.Parameters.Add("@SearchTerm", searchTerm); query.Parameters.Add("@SearchTermLike", searchTermLike); return query; @@ -258,7 +258,7 @@ public static ParameterizedQuery StartsWith(this ParameterizedQuery query, strin return query; var searchTermLike = $"{value}%"; - query.Sql.AppendLine($"and {field} like @SearchTermLike"); + query.Sql.AppendLine($"and {query.QuoteIdent(field)} like @SearchTermLike"); query.Parameters.Add($"@{field}", searchTermLike); return query; @@ -275,11 +275,11 @@ public static ParameterizedQuery Is(this ParameterizedQuery query, VersionOption var sql = query.Sql; var options = versionOptions.Value; - if (options.IsDraft) sql.AppendLine("and IsPublished = 0"); - if (options.IsLatest) sql.AppendLine("and IsLatest = 1"); - if (options.IsPublished) sql.AppendLine("and IsPublished = 1"); - if (options.IsLatestOrPublished) sql.AppendLine("and (IsLatest = 1 or IsPublished = 1)"); - if (options.IsLatestAndPublished) sql.AppendLine("and IsLatest = 1 and IsPublished = 1"); + if (options.IsDraft) sql.AppendLine($"and {query.QuoteIdent("IsPublished")} = 0"); + if (options.IsLatest) sql.AppendLine($"and {query.QuoteIdent("IsLatest")} = 1"); + if (options.IsPublished) sql.AppendLine($"and {query.QuoteIdent("IsPublished")} = 1"); + if (options.IsLatestOrPublished) sql.AppendLine($"and ({query.QuoteIdent("IsLatest")} = 1 or {query.QuoteIdent("IsPublished")} = 1)"); + if (options.IsLatestAndPublished) sql.AppendLine($"and {query.QuoteIdent("IsLatest")} = 1 and {query.QuoteIdent("IsPublished")} = 1"); if (options.Version > 0) { sql.AppendLine(query.Dialect.And("Version")); @@ -299,7 +299,7 @@ public static ParameterizedQuery AndWorkflowInstanceSearchTerm(this Parameterize if (string.IsNullOrWhiteSpace(searchTerm)) return query; var searchTermLike = $"%{searchTerm}%"; - query.Sql.AppendLine("and (Name like @SearchTermLike or ID like @SearchTerm or DefinitionId like @SearchTerm or DefinitionVersionId like @SearchTerm or CorrelationId like @SearchTerm)"); + query.Sql.AppendLine($"and ({query.QuoteIdent("Name")} like @SearchTermLike or {query.QuoteIdent("ID")} like @SearchTerm or {query.QuoteIdent("DefinitionId")} like @SearchTerm or {query.QuoteIdent("DefinitionVersionId")} like @SearchTerm or {query.QuoteIdent("CorrelationId")} like @SearchTerm)"); query.Parameters.Add("@SearchTerm", searchTerm); query.Parameters.Add("@SearchTermLike", searchTermLike); return query; @@ -328,7 +328,7 @@ public static ParameterizedQuery OrderBy(this ParameterizedQuer public static ParameterizedQuery OrderBy(this ParameterizedQuery query, string field, OrderDirection direction) { var directionString = direction == OrderDirection.Ascending ? "asc" : "desc"; - query.Sql.AppendLine($"order by {field} {directionString}"); + query.Sql.AppendLine($"order by {query.QuoteIdent(field)} {directionString}"); return query; } @@ -342,7 +342,7 @@ public static ParameterizedQuery OrderBy(this ParameterizedQuery query, params O if (!orderFields.Any()) return query; - var clauses = string.Join(",", orderFields.Select(x => $"{x.Field} {(x.Direction == OrderDirection.Ascending ? "asc" : "desc")}")); + var clauses = string.Join(",", orderFields.Select(x => $"{query.QuoteIdent(x.Field)} {(x.Direction == OrderDirection.Ascending ? "asc" : "desc")}")); query.Sql.AppendLine($"order by {clauses}"); return query; } @@ -510,4 +510,7 @@ public static ParameterizedQuery Update(this ParameterizedQuery query, string ta if (type == typeof(byte[])) return DbType.Binary; return null; } + + private static string QuoteIdent(this ParameterizedQuery query, string name) => + query.Dialect.QuoteIdentifier(name); } \ No newline at end of file diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowInstanceStore.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowInstanceStore.cs index d36a7e568b..df4745158e 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowInstanceStore.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowInstanceStore.cs @@ -194,7 +194,7 @@ public async ValueTask TryMarkInterruptedAsync(string workflowInstanceId, // Refuse every Finished row (#8419). Distinct param names avoid colliding // with SET Status = Running. q.Parameters.Add("@FinishedStatus", WorkflowStatus.Finished.ToString()); - q.Sql.AppendLine("and not Status = @FinishedStatus"); + q.Sql.AppendLine($"and not {q.Dialect.QuoteIdentifier("Status")} = @FinishedStatus"); }, cancellationToken); diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs index d74a3fd1d8..33f7003edc 100644 --- a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs @@ -7,8 +7,13 @@ using Elsa.Persistence.Dapper.Services; using Elsa.Workflows; using Elsa.Workflows.Activities; +using Elsa.Common.Entities; +using Elsa.Common.Models; +using Elsa.Persistence.Dapper.Models; using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Entities; using Elsa.Workflows.Management.Filters; +using Elsa.Workflows.Management.Models; using Elsa.Workflows.Options; using Elsa.Workflows.Runtime; using Elsa.Workflows.Runtime.Entities; @@ -73,6 +78,7 @@ public async Task FreshPostgreSqlDatabase_MigratesPersistsAndRunsWorkflowToCompl await PersistRecordsThroughDapperStores(); await RunWriteLineWorkflowToCompletion(); + await ExerciseListSearchOrderVersionAndPagedDelete(); } private async Task PersistRecordsThroughDapperStores() @@ -155,6 +161,152 @@ private async Task RunWriteLineWorkflowToCompletion() Assert.All(storedActivities, record => Assert.Equal(ActivityStatus.Completed, record.Status)); } + private async Task ExerciseListSearchOrderVersionAndPagedDelete() + { + using var services = CreateElsaHost(); + using var scope = services.CreateScope(); + var sp = scope.ServiceProvider; + var definitions = sp.GetRequiredService(); + var instances = sp.GetRequiredService(); + + var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + await definitions.SaveAsync(Definition("def-alpha", "def-alpha:1", "Alpha", version: 1, latest: false, published: true, createdAt: t0)); + await definitions.SaveAsync(Definition("def-alpha", "def-alpha:2", "Alpha", version: 2, latest: true, published: false, createdAt: t0.AddMinutes(1))); + await definitions.SaveAsync(Definition("def-bravo", "def-bravo:1", "BravoNeedle", version: 1, latest: true, published: true, createdAt: t0.AddMinutes(2))); + + var listed = (await definitions.FindManyAsync( + new WorkflowDefinitionFilter { DefinitionIds = ["def-alpha", "def-bravo"] }, + new WorkflowDefinitionOrder(x => x.Name, OrderDirection.Descending), + CancellationToken.None)).ToList(); + Assert.Equal(3, listed.Count); + Assert.Equal("BravoNeedle", listed[0].Name); + + var searched = (await definitions.FindManyAsync( + new WorkflowDefinitionFilter { SearchTerm = "Needle" }, + CancellationToken.None)).ToList(); + Assert.Equal(["def-bravo:1"], searched.Select(x => x.Id)); + + var latest = (await definitions.FindManyAsync( + new WorkflowDefinitionFilter { VersionOptions = VersionOptions.Latest }, + new WorkflowDefinitionOrder(x => x.CreatedAt, OrderDirection.Ascending), + CancellationToken.None)).ToList(); + Assert.Equal(["def-alpha:2", "def-bravo:1"], latest.Select(x => x.Id)); + + var published = (await definitions.FindManyAsync( + new WorkflowDefinitionFilter { VersionOptions = VersionOptions.Published }, + CancellationToken.None)).ToList(); + Assert.Equal(2, published.Count); + Assert.All(published, definition => Assert.True(definition.IsPublished)); + + var specific = await definitions.FindAsync(new WorkflowDefinitionFilter + { + DefinitionId = "def-alpha", + VersionOptions = VersionOptions.SpecificVersion(2) + }); + Assert.NotNull(specific); + Assert.Equal("def-alpha:2", specific.Id); + + var page = await instances.FindManyAsync( + new WorkflowInstanceFilter { DefinitionId = "def-pg-1" }, + PageArgs.FromRange(0, 10), + new WorkflowInstanceOrder(x => x.CreatedAt, OrderDirection.Descending)); + Assert.True(page.TotalCount >= 1); + + var named = (await instances.FindManyAsync( + new WorkflowInstanceFilter { SearchTerm = "def-pg-1" }, + new WorkflowInstanceOrder(x => x.Id, OrderDirection.Ascending))).ToList(); + Assert.Contains(named, instance => instance.DefinitionId == "def-pg-1"); + + var tenantAccessor = new TestTenantAccessor(); + var connectionProvider = new PostgreSqlDbConnectionProvider(_connectionString); + var instanceStore = new Store(connectionProvider, tenantAccessor, "WorkflowInstances"); + await instanceStore.SaveAsync(new WorkflowInstanceRecord + { + Id = "wf-page-old", + DefinitionId = "def-pg-page", + DefinitionVersionId = "def-pg-page:1", + Version = 1, + WorkflowState = "{}", + Status = "Finished", + SubStatus = "Finished", + Name = "Zebra", + CreatedAt = t0, + UpdatedAt = t0 + }); + await instanceStore.SaveAsync(new WorkflowInstanceRecord + { + Id = "wf-page-new", + DefinitionId = "def-pg-page", + DefinitionVersionId = "def-pg-page:1", + Version = 1, + WorkflowState = "{}", + Status = "Finished", + SubStatus = "Finished", + Name = "AlphaInst", + CreatedAt = t0.AddHours(1), + UpdatedAt = t0.AddHours(1) + }); + await instanceStore.SaveAsync(new WorkflowInstanceRecord + { + Id = "wf-page-mid", + DefinitionId = "def-pg-page", + DefinitionVersionId = "def-pg-page:1", + Version = 1, + WorkflowState = "{}", + Status = "Finished", + SubStatus = "Finished", + Name = "Middle", + CreatedAt = t0.AddMinutes(30), + UpdatedAt = t0.AddMinutes(30) + }); + + var ordered = (await instanceStore.FindManyAsync( + q => q.Is(nameof(WorkflowInstanceRecord.DefinitionId), "def-pg-page"), + nameof(WorkflowInstanceRecord.Name), + OrderDirection.Ascending, + tenantAgnostic: false)).ToList(); + Assert.Equal(["wf-page-new", "wf-page-mid", "wf-page-old"], ordered.Select(x => x.Id)); + + var searchHits = (await instanceStore.FindManyAsync( + q => q.AndWorkflowInstanceSearchTerm("Zebra"), + tenantAgnostic: false)).ToList(); + Assert.Equal(["wf-page-old"], searchHits.Select(x => x.Id)); + + var deleted = await instanceStore.DeleteAsync( + q => q.Is(nameof(WorkflowInstanceRecord.DefinitionId), "def-pg-page"), + PageArgs.FromRange(0, 1), + [new OrderField(nameof(WorkflowInstanceRecord.CreatedAt), OrderDirection.Descending)]); + Assert.Equal(1, deleted); + + var remaining = (await instanceStore.FindManyAsync( + q => q.Is(nameof(WorkflowInstanceRecord.DefinitionId), "def-pg-page"), + nameof(WorkflowInstanceRecord.CreatedAt), + OrderDirection.Descending, + tenantAgnostic: false)).ToList(); + Assert.Equal(["wf-page-mid", "wf-page-old"], remaining.Select(x => x.Id)); + } + + private static WorkflowDefinition Definition( + string definitionId, + string id, + string name, + int version, + bool latest, + bool published, + DateTimeOffset createdAt) => + new() + { + Id = id, + DefinitionId = definitionId, + Name = name, + Version = version, + IsLatest = latest, + IsPublished = published, + CreatedAt = createdAt, + MaterializerName = "Json", + StringData = "{}" + }; + private ServiceProvider CreateElsaHost() { var services = new ServiceCollection(); diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/NonPgQuerySqlSnapshotTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/NonPgQuerySqlSnapshotTests.cs new file mode 100644 index 0000000000..0c8d12953d --- /dev/null +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/NonPgQuerySqlSnapshotTests.cs @@ -0,0 +1,240 @@ +using Elsa.Common.Entities; +using Elsa.Common.Models; +using Elsa.Persistence.Dapper.Contracts; +using Elsa.Persistence.Dapper.Dialects; +using Elsa.Persistence.Dapper.Extensions; +using Elsa.Persistence.Dapper.Models; + +namespace Elsa.Persistence.Dapper.UnitTests; + +/// +/// Captured from the query builder on this branch before QuoteIdentifier was wired through +/// ParameterizedQueryBuilderExtensions. SQLite and SQL Server (the only non-PG dialects +/// in this repo; MySQL/Oracle use the same unquoted SqlDialectBase path) must stay +/// byte-identical to these strings. +/// +public sealed class NonPgQuerySqlSnapshotTests +{ + [Fact(DisplayName = "SQLite query-builder SQL is byte-identical to the pre-hook capture")] + public void Sqlite_QueryBuilderSql_IsByteIdenticalToPreHookCapture() => + AssertSnapshots(new SqliteDialect(), SqliteExpected); + + [Fact(DisplayName = "SQL Server query-builder SQL is byte-identical to the pre-hook capture")] + public void SqlServer_QueryBuilderSql_IsByteIdenticalToPreHookCapture() => + AssertSnapshots(new SqlServerDialect(), SqlServerExpected); + + [Fact(DisplayName = "Default QuoteIdentifier is a no-op on non-PG dialects")] + public void NonPg_QuoteIdentifier_ReturnsInputUnchanged() + { + ISqlDialect sqlite = new SqliteDialect(); + ISqlDialect sqlServer = new SqlServerDialect(); + Assert.Equal("CreatedAt", sqlite.QuoteIdentifier("CreatedAt")); + Assert.Equal("CreatedAt", sqlServer.QuoteIdentifier("CreatedAt")); + Assert.Same("CreatedAt", sqlite.QuoteIdentifier("CreatedAt")); + } + + private static void AssertSnapshots(ISqlDialect dialect, IReadOnlyDictionary expected) + { + foreach (var (label, sql) in BuildQueries(dialect)) + { + Assert.True(expected.ContainsKey(label), $"missing expected snapshot for {label}"); + Assert.Equal(expected[label], sql); + } + + Assert.Equal(expected.Count, BuildQueries(dialect).Count()); + } + + internal static IEnumerable<(string Label, string Sql)> BuildQueries(ISqlDialect dialect) + { + yield return ("from-and", new ParameterizedQuery(dialect) + .From("WorkflowDefinitions", "Id", "Name", "Version") + .Is("DefinitionId", "def-1") + .IsNot("Name", "skip") + .In("Id", new object[] { "a", "b" }) + .IsNull("Description") + .Sql.ToString()); + + yield return ("order-asc", new ParameterizedQuery(dialect) + .From("WorkflowInstances") + .OrderBy("CreatedAt", OrderDirection.Ascending) + .Sql.ToString()); + + yield return ("order-desc", new ParameterizedQuery(dialect) + .From("WorkflowInstances") + .OrderBy("CreatedAt", OrderDirection.Descending) + .Sql.ToString()); + + yield return ("order-multi", new ParameterizedQuery(dialect) + .From("WorkflowInstances") + .OrderBy( + new OrderField("Name", OrderDirection.Ascending), + new OrderField("CreatedAt", OrderDirection.Descending)) + .Sql.ToString()); + + yield return ("version-draft", new ParameterizedQuery(dialect) + .From("WorkflowDefinitions") + .Is(VersionOptions.Draft) + .Sql.ToString()); + + yield return ("version-latest", new ParameterizedQuery(dialect) + .From("WorkflowDefinitions") + .Is(VersionOptions.Latest) + .Sql.ToString()); + + yield return ("version-published", new ParameterizedQuery(dialect) + .From("WorkflowDefinitions") + .Is(VersionOptions.Published) + .Sql.ToString()); + + yield return ("version-latest-or-published", new ParameterizedQuery(dialect) + .From("WorkflowDefinitions") + .Is(VersionOptions.LatestOrPublished) + .Sql.ToString()); + + yield return ("version-latest-and-published", new ParameterizedQuery(dialect) + .From("WorkflowDefinitions") + .Is(VersionOptions.LatestAndPublished) + .Sql.ToString()); + + yield return ("version-specific", new ParameterizedQuery(dialect) + .From("WorkflowDefinitions") + .Is(VersionOptions.SpecificVersion(3)) + .Sql.ToString()); + + yield return ("definition-search", new ParameterizedQuery(dialect) + .From("WorkflowDefinitions") + .WorkflowDefinitionSearchTerm("alpha") + .Sql.ToString()); + + yield return ("instance-search", new ParameterizedQuery(dialect) + .From("WorkflowInstances") + .AndWorkflowInstanceSearchTerm("bravo") + .Sql.ToString()); + + yield return ("starts-with", new ParameterizedQuery(dialect) + .From("WorkflowInstances") + .StartsWith("Name", true, "pre") + .Sql.ToString()); + + var inner = new ParameterizedQuery(dialect) + .From("WorkflowInstances", "Id") + .Is("DefinitionId", "def-1") + .OrderBy(new OrderField("CreatedAt", OrderDirection.Descending)) + .Page(PageArgs.FromRange(0, 2)); + yield return ("paged-delete", new ParameterizedQuery(dialect) + .Delete("WorkflowInstances", "Id", inner) + .Sql.ToString()); + + yield return ("count", new ParameterizedQuery(dialect) + .Count("WorkflowDefinitions") + .Sql.ToString()); + + yield return ("count-distinct", new ParameterizedQuery(dialect) + .Count("distinct DefinitionId", "WorkflowDefinitions") + .Sql.ToString()); + + yield return ("upsert", new ParameterizedQuery(dialect) + .Upsert("WorkflowInstances", "Id", new SnapshotRecord { Id = "i1", Name = "n", Status = "Finished" }) + .Sql.ToString()); + } + + private static readonly IReadOnlyDictionary SharedExpected = new Dictionary + { + ["from-and"] = Join( + "select Id, Name, Version from WorkflowDefinitions where 1=1", + "and DefinitionId = @DefinitionId", + "and not Name = @Name", + "and Id in (@Id0, @Id1)", + "and Description is null"), + ["order-asc"] = Join( + "select * from WorkflowInstances where 1=1", + "order by CreatedAt asc"), + ["order-desc"] = Join( + "select * from WorkflowInstances where 1=1", + "order by CreatedAt desc"), + ["order-multi"] = Join( + "select * from WorkflowInstances where 1=1", + "order by Name asc,CreatedAt desc"), + ["version-draft"] = Join( + "select * from WorkflowDefinitions where 1=1", + "and IsPublished = 0"), + ["version-latest"] = Join( + "select * from WorkflowDefinitions where 1=1", + "and IsLatest = 1"), + ["version-published"] = Join( + "select * from WorkflowDefinitions where 1=1", + "and IsPublished = 1"), + ["version-latest-or-published"] = Join( + "select * from WorkflowDefinitions where 1=1", + "and (IsLatest = 1 or IsPublished = 1)"), + ["version-latest-and-published"] = Join( + "select * from WorkflowDefinitions where 1=1", + "and IsLatest = 1 and IsPublished = 1"), + ["version-specific"] = Join( + "select * from WorkflowDefinitions where 1=1", + "and Version = @Version"), + ["definition-search"] = Join( + "select * from WorkflowDefinitions where 1=1", + "and (Name like @SearchTermLike or Description like @SearchTermLike or Id like @SearchTerm or DefinitionId like @SearchTerm)"), + ["instance-search"] = Join( + "select * from WorkflowInstances where 1=1", + "and (Name like @SearchTermLike or ID like @SearchTerm or DefinitionId like @SearchTerm or DefinitionVersionId like @SearchTerm or CorrelationId like @SearchTerm)"), + ["starts-with"] = Join( + "select * from WorkflowInstances where 1=1", + "and Name like @SearchTermLike"), + ["count"] = Join("select COUNT(*) from WorkflowDefinitions where 1=1"), + ["count-distinct"] = Join("select COUNT(distinct DefinitionId) from WorkflowDefinitions where 1=1"), + }; + + private static readonly IReadOnlyDictionary SqliteExpected = Merge(SharedExpected, new Dictionary + { + ["paged-delete"] = Join( + "delete from WorkflowInstances where 1=1", + "and Id in (", + "select Id from WorkflowInstances where 1=1", + "and DefinitionId = @DefinitionId", + "order by CreatedAt desc", + "limit 2", + "offset 0", + "", + "", + ")"), + ["upsert"] = Join("INSERT OR REPLACE INTO WorkflowInstances (Id, Name, Status) VALUES (@Id, @Name, @Status);"), + }); + + private static readonly IReadOnlyDictionary SqlServerExpected = Merge(SharedExpected, new Dictionary + { + ["paged-delete"] = Join( + "delete from WorkflowInstances where 1=1", + "and Id in (", + "select Id from WorkflowInstances where 1=1", + "and DefinitionId = @DefinitionId", + "order by CreatedAt desc", + "offset 0 rows", + "fetch next 2 rows only", + "", + "", + ")"), + ["upsert"] = "\n MERGE INTO WorkflowInstances WITH (HOLDLOCK) AS Target\n USING (VALUES (@Id, @Name, @Status))\n AS Source (Id, Name, Status)\n ON Target.Id = Source.Id\n WHEN MATCHED THEN \n UPDATE SET Name = Source.Name, Status = Source.Status\n WHEN NOT MATCHED THEN\n INSERT (Id, Name, Status)\n VALUES (Source.Id, Source.Name, Source.Status);" + Environment.NewLine, + }); + + private static string Join(params string[] lines) => + string.Join(Environment.NewLine, lines) + Environment.NewLine; + + private static IReadOnlyDictionary Merge( + IReadOnlyDictionary left, + IReadOnlyDictionary right) + { + var merged = new Dictionary(left); + foreach (var (key, value) in right) + merged[key] = value; + return merged; + } + + public sealed class SnapshotRecord + { + public string Id { get; init; } = null!; + public string Name { get; init; } = null!; + public string Status { get; init; } = null!; + } +} diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs index 3949ab8480..d2ca5bc9fc 100644 --- a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs @@ -56,6 +56,19 @@ public void Quote_IsIdempotentAndEscapesEmbeddedQuotes() Assert.Equal("\"Id\"", PostgreSqlDialect.Quote("Id")); Assert.Equal("\"Id\"", PostgreSqlDialect.Quote("\"Id\"")); Assert.Equal("\"foo\"\"bar\"", PostgreSqlDialect.Quote("foo\"bar")); + Assert.Equal("\"CreatedAt\"", _dialect.QuoteIdentifier("CreatedAt")); + } + + [Fact(DisplayName = "Shared query-builder inlines go through QuoteIdentifier on PostgreSQL")] + public void QueryBuilder_QuotesInlinedIdentifiers() + { + var queries = NonPgQuerySqlSnapshotTests.BuildQueries(_dialect).ToDictionary(x => x.Label, x => x.Sql); + + Assert.Contains("order by \"CreatedAt\" desc", queries["order-desc"], StringComparison.Ordinal); + Assert.Contains("and \"IsLatest\" = 1", queries["version-latest"], StringComparison.Ordinal); + Assert.Contains("\"Name\" like @SearchTermLike", queries["definition-search"], StringComparison.Ordinal); + Assert.Contains("and \"Id\" in (", queries["paged-delete"], StringComparison.Ordinal); + Assert.DoesNotContain("order by CreatedAt desc\n", queries["order-desc"], StringComparison.Ordinal); } [Fact(DisplayName = "PG DateTimeOffset handler accepts DateTime, DateTimeOffset, and string")] From 389c1a7ebd2962f2ca8f3fd938097dfb86257df6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 28 Sep 2026 08:21:48 +0000 Subject: [PATCH 055/169] fix(dapper): emit PG boolean literals through a dialect hook VersionOptions compared boolean columns to 1/0, which PostgreSQL rejects (42883). ISqlDialect.BooleanLiteral defaults to 1/0 so non-PG SQL stays byte-identical; only PostgreSqlDialect emits true/false. The fresh-PG test now covers publish, run-by-definition, VersionOptions, Studio lists, journal and activity-execution reads, bookmark-queue paging, and paged delete. Co-authored-by: Sipke Schoorstra --- .../Abstractions/SqlDialectBase.cs | 3 + .../Contracts/ISqlDialect.cs | 10 ++ .../Dialects/PostgreSqlDialect.cs | 3 + .../ParameterizedQueryBuilderExtensions.cs | 13 ++- .../DapperPostgreSqlMigrationTests.cs | 95 +++++++++++++++++++ .../NonPgQuerySqlSnapshotTests.cs | 2 + .../PostgreSqlDialectTests.cs | 4 +- 7 files changed, 124 insertions(+), 6 deletions(-) diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Abstractions/SqlDialectBase.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Abstractions/SqlDialectBase.cs index 870f6ee6fe..d5100e3cad 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Abstractions/SqlDialectBase.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Abstractions/SqlDialectBase.cs @@ -13,6 +13,9 @@ public abstract class SqlDialectBase : ISqlDialect /// public virtual string QuoteIdentifier(string name) => name; + /// + public virtual string BooleanLiteral(bool value) => value ? "1" : "0"; + /// public virtual string From(string table) => From(table, "*"); diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Contracts/ISqlDialect.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Contracts/ISqlDialect.cs index 1371f5c949..e607b11fc8 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Contracts/ISqlDialect.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Contracts/ISqlDialect.cs @@ -18,6 +18,16 @@ public interface ISqlDialect /// string QuoteIdentifier(string name) => name; + /// + /// Returns a SQL boolean literal. + /// + /// + /// The default emits 1/0 so SQLite, SQL Server, MySQL and Oracle + /// SQL stays byte-identical. PostgreSQL overrides this to true/false + /// because boolean = integer is 42883. + /// + string BooleanLiteral(bool value) => value ? "1" : "0"; + /// /// Returns a SELECT FROM query. /// diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Dialects/PostgreSqlDialect.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Dialects/PostgreSqlDialect.cs index 07e2b06b89..2dbc57df34 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Dialects/PostgreSqlDialect.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Dialects/PostgreSqlDialect.cs @@ -28,6 +28,9 @@ public class PostgreSqlDialect : SqlDialectBase, ISqlDialect /// public override string QuoteIdentifier(string name) => Quote(name); + /// + public override string BooleanLiteral(bool value) => value ? "true" : "false"; + /// public override string From(string table) => From(table, "*"); diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs index 9795e88910..ef2b9cbaa3 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs @@ -275,11 +275,11 @@ public static ParameterizedQuery Is(this ParameterizedQuery query, VersionOption var sql = query.Sql; var options = versionOptions.Value; - if (options.IsDraft) sql.AppendLine($"and {query.QuoteIdent("IsPublished")} = 0"); - if (options.IsLatest) sql.AppendLine($"and {query.QuoteIdent("IsLatest")} = 1"); - if (options.IsPublished) sql.AppendLine($"and {query.QuoteIdent("IsPublished")} = 1"); - if (options.IsLatestOrPublished) sql.AppendLine($"and ({query.QuoteIdent("IsLatest")} = 1 or {query.QuoteIdent("IsPublished")} = 1)"); - if (options.IsLatestAndPublished) sql.AppendLine($"and {query.QuoteIdent("IsLatest")} = 1 and {query.QuoteIdent("IsPublished")} = 1"); + if (options.IsDraft) sql.AppendLine($"and {query.QuoteIdent("IsPublished")} = {query.BoolLit(false)}"); + if (options.IsLatest) sql.AppendLine($"and {query.QuoteIdent("IsLatest")} = {query.BoolLit(true)}"); + if (options.IsPublished) sql.AppendLine($"and {query.QuoteIdent("IsPublished")} = {query.BoolLit(true)}"); + if (options.IsLatestOrPublished) sql.AppendLine($"and ({query.QuoteIdent("IsLatest")} = {query.BoolLit(true)} or {query.QuoteIdent("IsPublished")} = {query.BoolLit(true)})"); + if (options.IsLatestAndPublished) sql.AppendLine($"and {query.QuoteIdent("IsLatest")} = {query.BoolLit(true)} and {query.QuoteIdent("IsPublished")} = {query.BoolLit(true)}"); if (options.Version > 0) { sql.AppendLine(query.Dialect.And("Version")); @@ -513,4 +513,7 @@ public static ParameterizedQuery Update(this ParameterizedQuery query, string ta private static string QuoteIdent(this ParameterizedQuery query, string name) => query.Dialect.QuoteIdentifier(name); + + private static string BoolLit(this ParameterizedQuery query, bool value) => + query.Dialect.BooleanLiteral(value); } \ No newline at end of file diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs index 33f7003edc..fbeb666797 100644 --- a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs @@ -3,6 +3,7 @@ using Elsa.Extensions; using Elsa.Persistence.Dapper.Extensions; using Elsa.Persistence.Dapper.Modules.Management.Records; +using Elsa.Persistence.Dapper.Modules.Management.Stores; using Elsa.Persistence.Dapper.Modules.Runtime.Records; using Elsa.Persistence.Dapper.Services; using Elsa.Workflows; @@ -19,6 +20,7 @@ using Elsa.Workflows.Runtime.Entities; using Elsa.Workflows.Runtime.Filters; using Elsa.Workflows.Runtime.Options; +using Elsa.Workflows.Runtime.OrderDefinitions; using FluentMigrator.Runner; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -206,6 +208,29 @@ private async Task ExerciseListSearchOrderVersionAndPagedDelete() Assert.NotNull(specific); Assert.Equal("def-alpha:2", specific.Id); + var latestOrPublished = (await definitions.FindManyAsync( + new WorkflowDefinitionFilter { DefinitionId = "def-alpha", VersionOptions = VersionOptions.LatestOrPublished }, + CancellationToken.None)).ToList(); + Assert.Equal(2, latestOrPublished.Count); + + var studioDefinitions = await definitions.FindSummariesAsync( + new WorkflowDefinitionFilter { VersionOptions = VersionOptions.Latest }, + new WorkflowDefinitionOrder(x => x.CreatedAt, OrderDirection.Ascending), + PageArgs.FromRange(0, 10)); + Assert.Contains(studioDefinitions.Items, summary => summary.DefinitionId == "def-bravo"); + + var publisher = sp.GetRequiredService(); + var draft = await publisher.NewAsync(new WriteLine("published from dapper postgres")); + draft.Name = "PublishMe"; + await publisher.SaveDraftAsync(draft); + var publishedDefinition = await publisher.PublishAsync(draft.DefinitionId); + Assert.True(publishedDefinition.Succeeded); + + var graph = await sp.GetRequiredService() + .FindWorkflowGraphAsync(draft.DefinitionId, VersionOptions.Published); + Assert.NotNull(graph); + Assert.Equal(draft.DefinitionId, graph.Workflow.Identity.DefinitionId); + var page = await instances.FindManyAsync( new WorkflowInstanceFilter { DefinitionId = "def-pg-1" }, PageArgs.FromRange(0, 10), @@ -217,6 +242,50 @@ private async Task ExerciseListSearchOrderVersionAndPagedDelete() new WorkflowInstanceOrder(x => x.Id, OrderDirection.Ascending))).ToList(); Assert.Contains(named, instance => instance.DefinitionId == "def-pg-1"); + var studioInstances = await instances.SummarizeManyAsync( + new WorkflowInstanceFilter { SearchTerm = "def-pg-1" }, + PageArgs.FromRange(0, 10), + new WorkflowInstanceOrder(x => x.CreatedAt, OrderDirection.Descending)); + Assert.True(studioInstances.TotalCount >= 1); + + var journal = sp.GetRequiredService(); + await journal.SaveAsync(new WorkflowExecutionLogRecord + { + Id = "log-pg-1", + WorkflowDefinitionId = "def-pg-1", + WorkflowDefinitionVersionId = "def-pg-1:1", + WorkflowInstanceId = "wf-pg-1", + WorkflowVersion = 1, + ActivityInstanceId = "act-inst-1", + ActivityId = "act-1", + ActivityType = "Elsa.WriteLine", + ActivityTypeVersion = 1, + ActivityNodeId = "node-1", + Timestamp = DateTimeOffset.UtcNow, + Sequence = 1, + EventName = "Executed" + }); + var journalPage = await journal.FindManyAsync( + new WorkflowExecutionLogRecordFilter { WorkflowInstanceId = "wf-pg-1" }, + PageArgs.FromRange(0, 10), + new WorkflowExecutionLogRecordOrder(x => x.Timestamp, OrderDirection.Descending)); + Assert.Contains(journalPage.Items, record => record.Id == "log-pg-1"); + + var activities = sp.GetRequiredService(); + var orderedActivities = (await activities.FindManyAsync( + new ActivityExecutionRecordFilter { WorkflowInstanceId = "wf-pg-1" }, + new ActivityExecutionRecordOrder(x => x.StartedAt, OrderDirection.Descending))).ToList(); + Assert.Contains(orderedActivities, record => record.Id == "rec-pg-1"); + + InsertBookmarkQueueRow("bq-1", "wf-pg-1", t0); + InsertBookmarkQueueRow("bq-2", "wf-pg-1", t0.AddMinutes(1)); + var queue = sp.GetRequiredService(); + var queuePage = await queue.PageAsync( + PageArgs.FromRange(0, 10), + new BookmarkQueueFilter { WorkflowInstanceId = "wf-pg-1" }, + new BookmarkQueueItemOrder(x => x.CreatedAt, OrderDirection.Descending)); + Assert.Equal(["bq-2", "bq-1"], queuePage.Items.Select(x => x.Id)); + var tenantAccessor = new TestTenantAccessor(); var connectionProvider = new PostgreSqlDbConnectionProvider(_connectionString); var instanceStore = new Store(connectionProvider, tenantAccessor, "WorkflowInstances"); @@ -284,6 +353,32 @@ await instanceStore.SaveAsync(new WorkflowInstanceRecord OrderDirection.Descending, tenantAgnostic: false)).ToList(); Assert.Equal(["wf-page-mid", "wf-page-old"], remaining.Select(x => x.Id)); + + await instanceStore.SaveAsync(new WorkflowInstanceRecord + { + Id = "wf-interrupt", + DefinitionId = "def-pg-page", + DefinitionVersionId = "def-pg-page:1", + Version = 1, + WorkflowState = "{}", + Status = "Running", + SubStatus = "Executing", + CreatedAt = t0, + UpdatedAt = t0 + }); + var interruptStore = new DapperWorkflowInstanceStore(instanceStore, sp.GetRequiredService()); + Assert.True(await interruptStore.TryMarkInterruptedAsync("wf-interrupt")); + } + + private void InsertBookmarkQueueRow(string id, string workflowInstanceId, DateTimeOffset createdAt) + { + using var connection = new NpgsqlConnection(_connectionString); + connection.Execute( + """ + INSERT INTO "BookmarkQueueItems" ("Id", "WorkflowInstanceId", "CreatedAt") + VALUES (@id, @workflowInstanceId, @createdAt) + """, + new { id, workflowInstanceId, createdAt }); } private static WorkflowDefinition Definition( diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/NonPgQuerySqlSnapshotTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/NonPgQuerySqlSnapshotTests.cs index 0c8d12953d..ea6cdeec8a 100644 --- a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/NonPgQuerySqlSnapshotTests.cs +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/NonPgQuerySqlSnapshotTests.cs @@ -31,6 +31,8 @@ public void NonPg_QuoteIdentifier_ReturnsInputUnchanged() Assert.Equal("CreatedAt", sqlite.QuoteIdentifier("CreatedAt")); Assert.Equal("CreatedAt", sqlServer.QuoteIdentifier("CreatedAt")); Assert.Same("CreatedAt", sqlite.QuoteIdentifier("CreatedAt")); + Assert.Equal("1", sqlite.BooleanLiteral(true)); + Assert.Equal("0", sqlServer.BooleanLiteral(false)); } private static void AssertSnapshots(ISqlDialect dialect, IReadOnlyDictionary expected) diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs index d2ca5bc9fc..6962c9ec5b 100644 --- a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs @@ -65,9 +65,11 @@ public void QueryBuilder_QuotesInlinedIdentifiers() var queries = NonPgQuerySqlSnapshotTests.BuildQueries(_dialect).ToDictionary(x => x.Label, x => x.Sql); Assert.Contains("order by \"CreatedAt\" desc", queries["order-desc"], StringComparison.Ordinal); - Assert.Contains("and \"IsLatest\" = 1", queries["version-latest"], StringComparison.Ordinal); + Assert.Contains("and \"IsLatest\" = true", queries["version-latest"], StringComparison.Ordinal); + Assert.Contains("and \"IsPublished\" = false", queries["version-draft"], StringComparison.Ordinal); Assert.Contains("\"Name\" like @SearchTermLike", queries["definition-search"], StringComparison.Ordinal); Assert.Contains("and \"Id\" in (", queries["paged-delete"], StringComparison.Ordinal); + Assert.DoesNotContain("= 1", queries["version-latest"], StringComparison.Ordinal); Assert.DoesNotContain("order by CreatedAt desc\n", queries["order-desc"], StringComparison.Ordinal); } From eec9581c64c79e8f62bee640aedef279d847f4f9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 28 Sep 2026 08:22:22 +0000 Subject: [PATCH 056/169] fix(dapper): quote LessThan identifiers on the 3.9 query builder 3.9-only BeforeLastUpdated uses LessThan, which still inlined the column name. Route it through QuoteIdentifier so PG quotes and SQLite/SQL Server SQL stays byte-identical. Co-authored-by: Sipke Schoorstra --- .../Extensions/ParameterizedQueryBuilderExtensions.cs | 2 +- .../DapperPostgreSqlMigrationTests.cs | 5 +++++ .../NonPgQuerySqlSnapshotTests.cs | 8 ++++++++ .../PostgreSqlDialectTests.cs | 1 + 4 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs index ef2b9cbaa3..831cb9fc61 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs @@ -175,7 +175,7 @@ public static ParameterizedQuery LessThan(this ParameterizedQuery query, string { if (value == null) return query; - query.Sql.AppendLine($"and {field} < @{field}"); + query.Sql.AppendLine($"and {query.QuoteIdent(field)} < @{field}"); query.Parameters.Add($"@{field}", value); return query; diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs index fbeb666797..338c49babc 100644 --- a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs @@ -248,6 +248,11 @@ private async Task ExerciseListSearchOrderVersionAndPagedDelete() new WorkflowInstanceOrder(x => x.CreatedAt, OrderDirection.Descending)); Assert.True(studioInstances.TotalCount >= 1); + var beforeLastUpdated = (await instances.FindManyAsync( + new WorkflowInstanceFilter { BeforeLastUpdated = DateTimeOffset.UtcNow.AddMinutes(1) }, + new WorkflowInstanceOrder(x => x.UpdatedAt, OrderDirection.Ascending))).ToList(); + Assert.Contains(beforeLastUpdated, instance => instance.Id == "wf-pg-1"); + var journal = sp.GetRequiredService(); await journal.SaveAsync(new WorkflowExecutionLogRecord { diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/NonPgQuerySqlSnapshotTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/NonPgQuerySqlSnapshotTests.cs index ea6cdeec8a..4fd16eb3ed 100644 --- a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/NonPgQuerySqlSnapshotTests.cs +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/NonPgQuerySqlSnapshotTests.cs @@ -118,6 +118,11 @@ private static void AssertSnapshots(ISqlDialect dialect, IReadOnlyDictionary Date: Mon, 28 Sep 2026 08:34:16 +0000 Subject: [PATCH 057/169] test(dapper): register WriteLine so PG publish rematerializes The slim Testcontainers host never called AddActivitiesFrom or IActivityRegistryPopulator, so PublishAsync NRE'd in CreateActivity when rematerializing the draft. Store SQL is unchanged. Co-authored-by: Sipke Schoorstra --- .../DapperPostgreSqlMigrationTests.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs index 338c49babc..9f493e7ae8 100644 --- a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs @@ -219,6 +219,8 @@ private async Task ExerciseListSearchOrderVersionAndPagedDelete() PageArgs.FromRange(0, 10)); Assert.Contains(studioDefinitions.Items, summary => summary.DefinitionId == "def-bravo"); + await sp.GetRequiredService().PopulateRegistryAsync(); + var publisher = sp.GetRequiredService(); var draft = await publisher.NewAsync(new WriteLine("published from dapper postgres")); draft.Name = "PublishMe"; @@ -412,6 +414,7 @@ private ServiceProvider CreateElsaHost() var services = new ServiceCollection(); services.AddLogging(); var module = services.CreateModule(); + module.AddActivitiesFrom(); module.UseDapper(dapper => { dapper.DbConnectionProvider = _ => new PostgreSqlDbConnectionProvider(_connectionString); From 3a58d186c4d694ff4cc322e35396a141f876a689 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 28 Sep 2026 08:43:34 +0000 Subject: [PATCH 058/169] fix(dapper): quote instance-search Id to match the PG schema AndWorkflowInstanceSearchTerm historically inlined unquoted ID, which is byte-identical on SQLite/SQL Server. Quoted on PG it is 42703 against FluentMigrator's "Id". Non-PG SQL is unchanged. Co-authored-by: Sipke Schoorstra --- .../Extensions/ParameterizedQueryBuilderExtensions.cs | 5 ++++- .../PostgreSqlDialectTests.cs | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs index 831cb9fc61..0373451f20 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs @@ -299,7 +299,10 @@ public static ParameterizedQuery AndWorkflowInstanceSearchTerm(this Parameterize if (string.IsNullOrWhiteSpace(searchTerm)) return query; var searchTermLike = $"%{searchTerm}%"; - query.Sql.AppendLine($"and ({query.QuoteIdent("Name")} like @SearchTermLike or {query.QuoteIdent("ID")} like @SearchTerm or {query.QuoteIdent("DefinitionId")} like @SearchTerm or {query.QuoteIdent("DefinitionVersionId")} like @SearchTerm or {query.QuoteIdent("CorrelationId")} like @SearchTerm)"); + // Non-PG SQL historically used unquoted ID (case-insensitive). PG quotes + // identifiers, so this must match FluentMigrator's "Id" or it is 42703. + var idColumn = query.Dialect.QuoteIdentifier("Id") == "Id" ? "ID" : "Id"; + query.Sql.AppendLine($"and ({query.QuoteIdent("Name")} like @SearchTermLike or {query.QuoteIdent(idColumn)} like @SearchTerm or {query.QuoteIdent("DefinitionId")} like @SearchTerm or {query.QuoteIdent("DefinitionVersionId")} like @SearchTerm or {query.QuoteIdent("CorrelationId")} like @SearchTerm)"); query.Parameters.Add("@SearchTerm", searchTerm); query.Parameters.Add("@SearchTermLike", searchTermLike); return query; diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs index eb62d14b12..af88388ea5 100644 --- a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs @@ -68,6 +68,8 @@ public void QueryBuilder_QuotesInlinedIdentifiers() Assert.Contains("and \"IsLatest\" = true", queries["version-latest"], StringComparison.Ordinal); Assert.Contains("and \"IsPublished\" = false", queries["version-draft"], StringComparison.Ordinal); Assert.Contains("\"Name\" like @SearchTermLike", queries["definition-search"], StringComparison.Ordinal); + Assert.Contains("\"Id\" like @SearchTerm", queries["instance-search"], StringComparison.Ordinal); + Assert.DoesNotContain("\"ID\"", queries["instance-search"], StringComparison.Ordinal); Assert.Contains("and \"Id\" in (", queries["paged-delete"], StringComparison.Ordinal); Assert.Contains("and \"UpdatedAt\" < @UpdatedAt", queries["less-than"], StringComparison.Ordinal); Assert.DoesNotContain("= 1", queries["version-latest"], StringComparison.Ordinal); From dc596edf0c18b044e0e54111e93b14e2d25eb288 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 28 Sep 2026 08:48:05 +0000 Subject: [PATCH 059/169] fix(dapper): use Id in instance search and unblock the PG E2E MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B4: AndWorkflowInstanceSearchTerm now quotes Id (same spelling as definition search). The non-PG snapshot changes one token (ID → Id); SQLite and default-collation SQL Server are case-insensitive. B5: rec-pg-1 uses ActivityStatus.Completed, and the paged delete is built in the test so inner parameters are not dropped. Co-authored-by: Sipke Schoorstra --- .../ParameterizedQueryBuilderExtensions.cs | 5 +---- .../DapperPostgreSqlMigrationTests.cs | 16 ++++++++++------ .../NonPgQuerySqlSnapshotTests.cs | 4 +++- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs index 0373451f20..879f22b265 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs @@ -299,10 +299,7 @@ public static ParameterizedQuery AndWorkflowInstanceSearchTerm(this Parameterize if (string.IsNullOrWhiteSpace(searchTerm)) return query; var searchTermLike = $"%{searchTerm}%"; - // Non-PG SQL historically used unquoted ID (case-insensitive). PG quotes - // identifiers, so this must match FluentMigrator's "Id" or it is 42703. - var idColumn = query.Dialect.QuoteIdentifier("Id") == "Id" ? "ID" : "Id"; - query.Sql.AppendLine($"and ({query.QuoteIdent("Name")} like @SearchTermLike or {query.QuoteIdent(idColumn)} like @SearchTerm or {query.QuoteIdent("DefinitionId")} like @SearchTerm or {query.QuoteIdent("DefinitionVersionId")} like @SearchTerm or {query.QuoteIdent("CorrelationId")} like @SearchTerm)"); + query.Sql.AppendLine($"and ({query.QuoteIdent("Name")} like @SearchTermLike or {query.QuoteIdent("Id")} like @SearchTerm or {query.QuoteIdent("DefinitionId")} like @SearchTerm or {query.QuoteIdent("DefinitionVersionId")} like @SearchTerm or {query.QuoteIdent("CorrelationId")} like @SearchTerm)"); query.Parameters.Add("@SearchTerm", searchTerm); query.Parameters.Add("@SearchTermLike", searchTermLike); return query; diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs index 9f493e7ae8..118e5b9fb2 100644 --- a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs @@ -101,7 +101,7 @@ private async Task PersistRecordsThroughDapperStores() ActivityName = "WriteLine", StartedAt = DateTimeOffset.UtcNow, HasBookmarks = false, - Status = "Finished", + Status = "Completed", SerializedMetadata = """{"source":"fresh-pg"}""", AggregateFaultCount = 2 }; @@ -348,11 +348,15 @@ await instanceStore.SaveAsync(new WorkflowInstanceRecord tenantAgnostic: false)).ToList(); Assert.Equal(["wf-page-old"], searchHits.Select(x => x.Id)); - var deleted = await instanceStore.DeleteAsync( - q => q.Is(nameof(WorkflowInstanceRecord.DefinitionId), "def-pg-page"), - PageArgs.FromRange(0, 1), - [new OrderField(nameof(WorkflowInstanceRecord.CreatedAt), OrderDirection.Descending)]); - Assert.Equal(1, deleted); + var inner = connectionProvider.CreateQuery() + .From("WorkflowInstances", "Id") + .Is(nameof(WorkflowInstanceRecord.DefinitionId), "def-pg-page") + .OrderBy(new OrderField(nameof(WorkflowInstanceRecord.CreatedAt), OrderDirection.Descending)) + .Page(PageArgs.FromRange(0, 1)); + var delete = connectionProvider.CreateQuery().Delete("WorkflowInstances", "Id", inner); + delete.Parameters.AddDynamicParams(inner.Parameters); // Store.DeleteAsync drops these today (tracked separately) + using (var connection = connectionProvider.GetConnection()) + Assert.Equal(1, await connection.ExecuteAsync(delete.Sql.ToString(), delete.Parameters)); var remaining = (await instanceStore.FindManyAsync( q => q.Is(nameof(WorkflowInstanceRecord.DefinitionId), "def-pg-page"), diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/NonPgQuerySqlSnapshotTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/NonPgQuerySqlSnapshotTests.cs index 4fd16eb3ed..bcad23a87a 100644 --- a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/NonPgQuerySqlSnapshotTests.cs +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/NonPgQuerySqlSnapshotTests.cs @@ -183,9 +183,11 @@ private static void AssertSnapshots(ISqlDialect dialect, IReadOnlyDictionary Date: Mon, 28 Sep 2026 08:49:12 +0000 Subject: [PATCH 060/169] =?UTF-8?q?docs(dapper):=20note=20that=20ID?= =?UTF-8?q?=E2=86=92Id=20fixes=20case-sensitive=20SQL=20Server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The instance-search snapshot token is accepted. It also fixes instance search on case-sensitive SQL Server collations, where the column is Id. Co-authored-by: Sipke Schoorstra --- .../NonPgQuerySqlSnapshotTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/NonPgQuerySqlSnapshotTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/NonPgQuerySqlSnapshotTests.cs index bcad23a87a..49fa53a0b1 100644 --- a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/NonPgQuerySqlSnapshotTests.cs +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/NonPgQuerySqlSnapshotTests.cs @@ -183,8 +183,8 @@ private static void AssertSnapshots(ISqlDialect dialect, IReadOnlyDictionary Date: Mon, 28 Sep 2026 08:56:13 +0000 Subject: [PATCH 061/169] fix(dapper): create KeyValues, add SerializedOptions, and fix prefix lookups Main port of #266. Guarded runtime migration 20008 creates KeyValues and adds BookmarkQueueItems.SerializedOptions. ApplyFilter is XOR (prefix or exact key). StartsWith binds @{field}StartsWith. Co-authored-by: Sipke Schoorstra --- .../Runtime/V3_9.cs | 39 +++ .../ParameterizedQueryBuilderExtensions.cs | 6 +- .../Modules/Runtime/Stores/KeyValueStore.cs | 10 +- .../DapperKeyValuesAndBookmarkQueueTests.cs | 287 ++++++++++++++++++ .../Elsa.Persistence.Dapper.UnitTests.csproj | 1 + ...arameterizedQueryBuilderExtensionsTests.cs | 16 + 6 files changed, 352 insertions(+), 7 deletions(-) create mode 100644 src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/V3_9.cs create mode 100644 test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValuesAndBookmarkQueueTests.cs diff --git a/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/V3_9.cs b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/V3_9.cs new file mode 100644 index 0000000000..7093264dde --- /dev/null +++ b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/V3_9.cs @@ -0,0 +1,39 @@ +using System.Diagnostics.CodeAnalysis; +using FluentMigrator; +using JetBrains.Annotations; +using static System.Int32; + +namespace Elsa.Persistence.Dapper.Migrations.Runtime; + +/// +/// Creates the KeyValues table used by DapperKeyValueStore (#263) +/// and adds BookmarkQueueItems.SerializedOptions (#264). +/// Both operations are existence-guarded so hand-created workarounds keep working. +/// +[Migration(20008, "Elsa:Runtime:V3.9")] +[PublicAPI] +[SuppressMessage("ReSharper", "InconsistentNaming")] +public class V3_9 : Migration +{ + /// + public override void Up() + { + if (!Schema.Table("KeyValues").Exists()) + Create.Table("KeyValues") + .WithColumn("Id").AsString().PrimaryKey() + .WithColumn("TenantId").AsString().Nullable() + .WithColumn("Value").AsString(MaxValue).Nullable(); + + if (!Schema.Table("BookmarkQueueItems").Column("SerializedOptions").Exists()) + Alter.Table("BookmarkQueueItems").AddColumn("SerializedOptions").AsString(MaxValue).Nullable(); + } + + /// + /// + /// Leave KeyValues and SerializedOptions in place. Rolling them back would + /// drop a hand-created KeyValues table or SerializedOptions column and their data. + /// + public override void Down() + { + } +} diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs index 1739d6d878..03a8111ba7 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs @@ -241,9 +241,9 @@ public static ParameterizedQuery StartsWith(this ParameterizedQuery query, strin if (!startsWith || value == null || string.IsNullOrWhiteSpace(value)) return query; - var searchTermLike = $"{value}%"; - query.Sql.AppendLine($"and {query.QuoteIdent(field)} like @SearchTermLike"); - query.Parameters.Add($"@{field}", searchTermLike); + var parameterName = $"@{field}StartsWith"; + query.Sql.AppendLine($"and {query.QuoteIdent(field)} like {parameterName}"); + query.Parameters.Add(parameterName, $"{value}%"); return query; } diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Runtime/Stores/KeyValueStore.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Runtime/Stores/KeyValueStore.cs index 03725c44a6..cb3dd0dbe9 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Runtime/Stores/KeyValueStore.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Runtime/Stores/KeyValueStore.cs @@ -44,10 +44,12 @@ public Task DeleteAsync(string key, CancellationToken cancellationToken) private void ApplyFilter(ParameterizedQuery query, KeyValueFilter filter) { - query - .Is(nameof(KeyValuePairRecord.Id), filter.Key) - .In(nameof(KeyValuePairRecord.Id), filter.Keys) - .StartsWith(nameof(KeyValuePairRecord.Id), filter.StartsWith, filter.Key); + if (filter.StartsWith) + query.StartsWith(nameof(KeyValuePairRecord.Id), true, filter.Key); + else + query.Is(nameof(KeyValuePairRecord.Id), filter.Key); + + query.In(nameof(KeyValuePairRecord.Id), filter.Keys); } private KeyValuePairRecord Map(SerializedKeyValuePair source) diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValuesAndBookmarkQueueTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValuesAndBookmarkQueueTests.cs new file mode 100644 index 0000000000..d1f8e5f76b --- /dev/null +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValuesAndBookmarkQueueTests.cs @@ -0,0 +1,287 @@ +using Dapper; +using Elsa.Common.Multitenancy; +using Elsa.Extensions; +using Elsa.KeyValues.Contracts; +using Elsa.KeyValues.Entities; +using Elsa.KeyValues.Models; +using Elsa.Persistence.Dapper.Extensions; +using Elsa.Persistence.Dapper.Services; +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Filters; +using Elsa.Workflows.Runtime; +using Elsa.Workflows.Runtime.Activities; +using Elsa.Workflows.Runtime.Entities; +using Elsa.Workflows.Runtime.Filters; +using Elsa.Workflows.Runtime.Models; +using Elsa.Workflows.Runtime.Options; +using FluentMigrator.Runner; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Persistence.Dapper.UnitTests; + +/// +/// Fresh DBs are built only by FluentMigrator. That is what issues #263 and #264 +/// actually break on: the stores write columns/tables the older migrations never created. +/// +public sealed class DapperKeyValuesAndBookmarkQueueTests : IDisposable +{ + private readonly string _databasePath = Path.Combine(Path.GetTempPath(), $"elsa-dapper-kv-bq-{Guid.NewGuid():N}.db"); + private readonly string _connectionString; + + public DapperKeyValuesAndBookmarkQueueTests() + { + _connectionString = new SqliteConnectionStringBuilder { DataSource = _databasePath, Pooling = false }.ToString(); + } + + [Fact(DisplayName = "#263: a migration-built DB can Save / Find / FindMany / Delete through IKeyValueStore")] + public async Task FreshMigratedDatabase_KeyValueStore_RoundTrips() + { + // Arrange + MigrateUp(); + await using var services = CreateElsaServices(); + using var scope = services.CreateScope(); + using var tenant = scope.ServiceProvider.GetRequiredService().PushContext(Tenant.Default); + var store = scope.ServiceProvider.GetRequiredService(); + + await store.SaveAsync(Pair("app:alpha", "one"), CancellationToken.None); + await store.SaveAsync(Pair("app:beta", "two"), CancellationToken.None); + await store.SaveAsync(Pair("other:gamma", "three"), CancellationToken.None); + + // Act + var alpha = await store.FindAsync(new KeyValueFilter { Key = "app:alpha" }, CancellationToken.None); + var many = (await store.FindManyAsync(new KeyValueFilter { Keys = ["app:alpha", "app:beta"] }, CancellationToken.None)).ToList(); + await store.DeleteAsync("app:beta", CancellationToken.None); + var deleted = await store.FindAsync(new KeyValueFilter { Key = "app:beta" }, CancellationToken.None); + var remaining = (await store.FindManyAsync(new KeyValueFilter { Keys = ["app:alpha", "app:beta", "other:gamma"] }, CancellationToken.None)).ToList(); + + // Assert + Assert.True(TableExists("KeyValues")); + Assert.Equal("one", alpha?.SerializedValue); + Assert.Equal(["app:alpha", "app:beta"], many.Select(x => x.Key).Order().ToArray()); + Assert.Null(deleted); + Assert.Equal(["app:alpha", "other:gamma"], remaining.Select(x => x.Key).Order().ToArray()); + } + + [Fact(DisplayName = "#263: prefix FindMany returns only matching keys and exact Find still works")] + public async Task FreshMigratedDatabase_KeyValueStore_PrefixFindMany_ReturnsMatchingKeys() + { + // Arrange + MigrateUp(); + await using var services = CreateElsaServices(); + using var scope = services.CreateScope(); + using var tenant = scope.ServiceProvider.GetRequiredService().PushContext(Tenant.Default); + var store = scope.ServiceProvider.GetRequiredService(); + + await store.SaveAsync(Pair("app:1", "one"), CancellationToken.None); + await store.SaveAsync(Pair("app:2", "two"), CancellationToken.None); + await store.SaveAsync(Pair("other:1", "other"), CancellationToken.None); + + // Act + var prefixed = (await store.FindManyAsync(new KeyValueFilter { Key = "app:", StartsWith = true }, CancellationToken.None)).ToList(); + var exact = await store.FindAsync(new KeyValueFilter { Key = "app:1" }, CancellationToken.None); + var missing = await store.FindAsync(new KeyValueFilter { Key = "app:" }, CancellationToken.None); + + // Assert + Assert.Equal(["app:1", "app:2"], prefixed.Select(x => x.Key).Order().ToArray()); + Assert.DoesNotContain(prefixed, x => x.Key == "other:1"); + Assert.Equal("one", exact?.SerializedValue); + Assert.Null(missing); + } + + [Fact(DisplayName = "#263: the KV outbox store can Save and FindMany on a migration-built DB")] + public async Task FreshMigratedDatabase_OutboxStore_SaveAndFindMany() + { + // Arrange + MigrateUp(); + await using var services = CreateElsaServices(); + using var scope = services.CreateScope(); + using var tenant = scope.ServiceProvider.GetRequiredService().PushContext(Tenant.Default); + var outbox = scope.ServiceProvider.GetRequiredService(); + var item = new WorkflowDispatchOutboxItem + { + Id = "outbox-1", + OwnerWorkflowInstanceId = "wf-owner", + Kind = WorkflowDispatchOutboxItemKind.WorkflowDefinition, + CreatedAt = DateTimeOffset.Parse("2026-09-28T00:00:00+00:00") + }; + + // Act + await outbox.SaveAsync(item, CancellationToken.None); + var found = (await outbox.FindManyAsync(CancellationToken.None)).ToList(); + + // Assert + var loaded = Assert.Single(found); + Assert.Equal("outbox-1", loaded.Id); + Assert.Equal("wf-owner", loaded.OwnerWorkflowInstanceId); + } + + [Fact(DisplayName = "#264: a migration-built DB can enqueue a bookmark-queue item with Options and read it back")] + public async Task FreshMigratedDatabase_BookmarkQueueStore_RoundTripsOptions() + { + // Arrange + MigrateUp(); + await using var services = CreateElsaServices(); + using var scope = services.CreateScope(); + using var tenant = scope.ServiceProvider.GetRequiredService().PushContext(Tenant.Default); + var store = scope.ServiceProvider.GetRequiredService(); + var item = new BookmarkQueueItem + { + Id = "bq-1", + WorkflowInstanceId = "wf-1", + BookmarkId = "bm-1", + StimulusHash = "hash-1", + ActivityInstanceId = "act-1", + ActivityTypeName = "Elsa.Event", + CreatedAt = DateTimeOffset.Parse("2026-09-28T00:00:00+00:00"), + Options = new ResumeBookmarkOptions + { + Input = new Dictionary { ["payload"] = "hello" }, + Properties = new Dictionary { ["source"] = "test" } + } + }; + + // Act + await store.AddAsync(item, CancellationToken.None); + var loaded = await store.FindAsync(new BookmarkQueueFilter { Id = item.Id }, CancellationToken.None); + + // Assert + Assert.Contains("SerializedOptions", TableColumns("BookmarkQueueItems")); + Assert.NotNull(loaded); + Assert.NotNull(loaded.Options); + Assert.Equal("hello", loaded.Options.Input?["payload"]?.ToString()); + Assert.Equal("test", loaded.Options.Properties?["source"]?.ToString()); + } + + [Fact(DisplayName = "#264: Event start + resume completes on a migration-built Dapper DB")] + public async Task FreshMigratedDatabase_EventResumeWorkflow_ReachesFinished() + { + // Arrange + MigrateUp(); + await using var services = CreateElsaServices(elsa => elsa.AddWorkflow()); + using var scope = services.CreateScope(); + var sp = scope.ServiceProvider; + using var tenant = sp.GetRequiredService().PushContext(Tenant.Default); + await sp.GetRequiredService().PopulateAsync(); + var publisher = sp.GetRequiredService(); + var instances = sp.GetRequiredService(); + + // Act + await publisher.PublishAsync("probe-start"); + var afterStart = (await instances.FindManyAsync(new WorkflowInstanceFilter())).ToList(); + await publisher.PublishAsync("probe-resume"); + var afterResume = (await instances.FindManyAsync(new WorkflowInstanceFilter())).ToList(); + + // Assert + var started = Assert.Single(afterStart); + Assert.Equal(WorkflowStatus.Running, started.Status); + var finished = Assert.Single(afterResume); + Assert.Equal(WorkflowStatus.Finished, finished.Status); + } + + [Fact(DisplayName = "V3_9 is a no-op when KeyValues and SerializedOptions already exist")] + public void Migration_IsNoOp_WhenKeyValuesAndSerializedOptionsAlreadyExist() + { + // Arrange: stop at V3_7, then hand-create the objects a user may already have. + MigrateUp(20007); + Assert.False(TableExists("KeyValues")); + Assert.DoesNotContain("SerializedOptions", TableColumns("BookmarkQueueItems")); + + using (var connection = new SqliteConnection(_connectionString)) + { + connection.Execute(""" + create table KeyValues ( + Id text not null primary key, + TenantId text null, + Value text null + ); + insert into KeyValues (Id, TenantId, Value) values ('kept-key', '', 'kept-value'); + alter table BookmarkQueueItems add column SerializedOptions text null; + """); + } + + // Act + MigrateUp(); + + // Assert + Assert.True(TableExists("KeyValues")); + Assert.Contains("SerializedOptions", TableColumns("BookmarkQueueItems")); + using var read = new SqliteConnection(_connectionString); + var value = read.ExecuteScalar("select Value from KeyValues where Id = 'kept-key'"); + Assert.Equal("kept-value", value); + } + + public void Dispose() + { + File.Delete(_databasePath); + } + + private ServiceProvider CreateElsaServices(Action? extra = null) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddElsa(elsa => + { + elsa.UseDapper(d => d.DbConnectionProvider = _ => new SqliteDbConnectionProvider(_connectionString)); + elsa.UseWorkflowManagement(m => m.UseDapper()); + elsa.UseWorkflowRuntime(r => r.UseDapper()); + extra?.Invoke(elsa); + }); + return services.BuildServiceProvider(); + } + + private void MigrateUp(long? targetVersion = null) + { + using var services = new ServiceCollection() + .AddFluentMigratorCore() + .ConfigureRunner(rb => rb + .AddSQLite() + .WithGlobalConnectionString(_connectionString) + .ScanIn(typeof(Elsa.Persistence.Dapper.Migrations.Runtime.Initial).Assembly).For.Migrations()) + .BuildServiceProvider(false); + using var scope = services.CreateScope(); + var runner = scope.ServiceProvider.GetRequiredService(); + if (targetVersion is { } version) + runner.MigrateUp(version); + else + runner.MigrateUp(); + } + + private IReadOnlyCollection TableColumns(string tableName) + { + using var connection = new SqliteConnection(_connectionString); + return connection.Query($"SELECT name FROM pragma_table_info('{tableName}')").ToList(); + } + + private bool TableExists(string tableName) + { + using var connection = new SqliteConnection(_connectionString); + return connection.ExecuteScalar( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = @tableName", + new { tableName }) == 1; + } + + private static SerializedKeyValuePair Pair(string key, string value) => new() + { + Key = key, + SerializedValue = value + }; + + private sealed class EventResumeProbeWorkflow : WorkflowBase + { + protected override void Build(IWorkflowBuilder builder) + { + builder.Root = new Sequence + { + Activities = + { + new Event("probe-start") { CanStartWorkflow = true }, + new Event("probe-resume"), + new WriteLine("resumed") + } + }; + } + } +} diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/Elsa.Persistence.Dapper.UnitTests.csproj b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/Elsa.Persistence.Dapper.UnitTests.csproj index 6b1868be85..47666a7386 100644 --- a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/Elsa.Persistence.Dapper.UnitTests.csproj +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/Elsa.Persistence.Dapper.UnitTests.csproj @@ -1,6 +1,7 @@  + diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/ParameterizedQueryBuilderExtensionsTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/ParameterizedQueryBuilderExtensionsTests.cs index 34bba44b08..5c2e260b97 100644 --- a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/ParameterizedQueryBuilderExtensionsTests.cs +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/ParameterizedQueryBuilderExtensionsTests.cs @@ -1,6 +1,7 @@ using Elsa.Common.Multitenancy; using Elsa.Common.Entities; using Elsa.Common.Models; +using Elsa.Persistence.Dapper.Dialects; using Elsa.Persistence.Dapper.Extensions; using Elsa.Persistence.Dapper.Models; using Elsa.Persistence.Dapper.Services; @@ -83,6 +84,21 @@ public async Task In_WithValues_DoesNotMatchAnotherTenant() Assert.Equal(0, result.TotalCount); } + [Fact(DisplayName = "StartsWith binds @{field}StartsWith so it does not collide with Is(@{field})")] + public void StartsWith_BindsFieldPrefixedParameter() + { + var query = new ParameterizedQuery(new SqliteDialect()) + .From("KeyValues") + .StartsWith("Id", true, "app:"); + + var sql = query.Sql.ToString(); + + Assert.Contains("and Id like @IdStartsWith", sql, StringComparison.Ordinal); + Assert.DoesNotContain("@SearchTermLike", sql, StringComparison.Ordinal); + Assert.Equal("app:%", query.Parameters.Get("IdStartsWith")); + Assert.DoesNotContain("Id", query.Parameters.ParameterNames.Where(name => name == "Id")); + } + [Fact] public async Task In_WithTenantAgnosticFilter_ReturnsMatchingRowsAcrossTenants() { From e1b92e103f0cde0d8a8d99c05b68b13a87479a32 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 28 Sep 2026 09:07:43 +0000 Subject: [PATCH 062/169] fix(persistence): atomic TryDeleteAsync on Dapper and Mongo (#260) Port of #266's #260 commits. Dapper uses Store.DeleteAsync row count; Mongo uses DeleteOneAsync(ApplyTenantScope(...)).DeletedCount. Default-tenant Dapper reads/deletes match NULL or '' TenantId. ElsaVersion stays 3.10.0-preview.5722: no published 3.10 package yet has a nuspec commit at or after core main c1c935ce (#8538). TryDeleteAsync is on the concrete stores until that pin lands. Co-authored-by: Sipke Schoorstra --- .../ParameterizedQueryBuilderExtensions.cs | 10 + .../Modules/Runtime/Stores/KeyValueStore.cs | 9 + .../Elsa.Persistence.Dapper/Services/Store.cs | 6 +- .../Common/MongoDbStore.cs | 7 +- .../Modules/Runtime/KeyValueStore.cs | 12 ++ .../MongoKeyValueStoreTryDeleteTests.cs | 158 ++++++++++++++ .../DapperKeyValueStoreTryDeleteTests.cs | 193 ++++++++++++++++++ ...arameterizedQueryBuilderExtensionsTests.cs | 10 + 8 files changed, 403 insertions(+), 2 deletions(-) create mode 100644 test/modules/persistence/Elsa.MongoDb.UnitTests/MongoKeyValueStoreTryDeleteTests.cs create mode 100644 test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValueStoreTryDeleteTests.cs diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs index 03a8111ba7..a41eb8964e 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs @@ -154,6 +154,16 @@ public static ParameterizedQuery IsNull(this ParameterizedQuery query, string fi return query; } + /// + /// Matches a default-tenant stamp: NULL or empty string. + /// Legacy rows may use either; is ''. + /// + public static ParameterizedQuery IsNullOrEmpty(this ParameterizedQuery query, string field) + { + query.Sql.AppendLine($"and ({field} is null or {field} = '')"); + return query; + } + /// /// Appends an IS NOT NULL clause to the query. /// diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Runtime/Stores/KeyValueStore.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Runtime/Stores/KeyValueStore.cs index cb3dd0dbe9..5781fd560c 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Runtime/Stores/KeyValueStore.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Runtime/Stores/KeyValueStore.cs @@ -42,6 +42,15 @@ public Task DeleteAsync(string key, CancellationToken cancellationToken) return store.DeleteAsync(query => query.Is(nameof(KeyValuePairRecord.Id), key), cancellationToken); } + /// + /// Atomic delete: returns true only when the DELETE affected a row. + /// Becomes IKeyValueStore.TryDeleteAsync once ElsaVersion includes elsa-core#8538. + /// + public async Task TryDeleteAsync(string key, CancellationToken cancellationToken = default) + { + return await store.DeleteAsync(query => query.Is(nameof(KeyValuePairRecord.Id), key), cancellationToken) > 0; + } + private void ApplyFilter(ParameterizedQuery query, KeyValueFilter filter) { if (filter.StartsWith) diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Services/Store.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Services/Store.cs index 91d1db4793..fe0857d2d8 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Services/Store.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Services/Store.cs @@ -542,7 +542,11 @@ private void ApplyTenantFilter(ParameterizedQuery query, bool tenantAgnostic = f var tenant = tenantAccessor.Tenant; var tenantId = tenant?.Id; - query.Is(nameof(Record.TenantId), (object?)tenantId ?? DBNull.Value); + // Default tenant is '' in Elsa and NULL on some legacy rows (#245 / #260). + if (string.IsNullOrEmpty(tenantId)) + query.IsNullOrEmpty(nameof(Record.TenantId)); + else + query.Is(nameof(Record.TenantId), tenantId); } private void SetTenantId(T record) diff --git a/src/modules/persistence/Elsa.Persistence.MongoDb/Common/MongoDbStore.cs b/src/modules/persistence/Elsa.Persistence.MongoDb/Common/MongoDbStore.cs index 19df4c4878..85144a7fb6 100644 --- a/src/modules/persistence/Elsa.Persistence.MongoDb/Common/MongoDbStore.cs +++ b/src/modules/persistence/Elsa.Persistence.MongoDb/Common/MongoDbStore.cs @@ -509,7 +509,12 @@ private FilterDefinition CreateTenantOwnedUpsertFilter(TDocument docu & Builders.Filter.Where(x => (x as Entity)!.TenantId == writerTenantId || (x as Entity)!.TenantId == Tenant.AgnosticTenantId); } - private FilterDefinition ApplyTenantScope(FilterDefinition filter, bool tenantAgnostic) + /// + /// ANDs the current tenant onto . No-ops when + /// is true or is not an . + /// Writes using this filter match only the ambient tenant, not tenant-agnostic ("*") rows. + /// + public FilterDefinition ApplyTenantScope(FilterDefinition filter, bool tenantAgnostic = false) { if (tenantAgnostic || !typeof(Entity).IsAssignableFrom(typeof(TDocument))) return filter; diff --git a/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Runtime/KeyValueStore.cs b/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Runtime/KeyValueStore.cs index ccbf94b8be..b3de372b2c 100644 --- a/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Runtime/KeyValueStore.cs +++ b/src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Runtime/KeyValueStore.cs @@ -3,6 +3,7 @@ using Elsa.KeyValues.Models; using Elsa.Persistence.MongoDb.Common; using JetBrains.Annotations; +using MongoDB.Driver; using MongoDB.Driver.Linq; namespace Elsa.Persistence.MongoDb.Modules.Runtime; @@ -37,6 +38,17 @@ public Task DeleteAsync(string key, CancellationToken cancellationToken) return keyValueMongoDbStore.DeleteWhereAsync(x => x.Key == key, cancellationToken); } + /// + /// Atomic delete: returns true only when DeleteOneAsync removed a document. + /// Becomes IKeyValueStore.TryDeleteAsync once ElsaVersion includes elsa-core#8538. + /// + public async Task TryDeleteAsync(string key, CancellationToken cancellationToken = default) + { + var filter = keyValueMongoDbStore.ApplyTenantScope(Builders.Filter.Eq(x => x.Key, key)); + var result = await keyValueMongoDbStore.GetCollection().DeleteOneAsync(filter, cancellationToken); + return result.DeletedCount > 0; + } + private IQueryable Filter(IQueryable queryable, KeyValueFilter filter) { return filter.Apply(queryable); diff --git a/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoKeyValueStoreTryDeleteTests.cs b/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoKeyValueStoreTryDeleteTests.cs new file mode 100644 index 0000000000..690a135e3a --- /dev/null +++ b/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoKeyValueStoreTryDeleteTests.cs @@ -0,0 +1,158 @@ +using Elsa.Common.Multitenancy; +using Elsa.KeyValues.Contracts; +using Elsa.KeyValues.Entities; +using Elsa.KeyValues.Models; +using Elsa.Persistence.MongoDb.Common; +using Elsa.Persistence.MongoDb.Modules.Runtime; +using MongoDB.Driver; +using Testcontainers.MongoDb; + +namespace Elsa.MongoDb.UnitTests; + +/// +/// Atomic TryDeleteAsync against a shared Mongo collection (#260). +/// Calls the Mongo store method directly; IKeyValueStore.TryDeleteAsync is not +/// in 3.10.0-preview.5722 yet (waiting for a core pin at or after c1c935ce). +/// +public sealed class MongoKeyValueStoreTryDeleteTests : IAsyncLifetime +{ + private const string LegacyKey = "elsa.quiescence.pause.default"; + private readonly MongoDbContainer _container = new MongoDbBuilder().WithImage("mongo:7.0.24").Build(); + private readonly TestTenantAccessor _tenants = new(); + private MongoClient? _client; + private IMongoCollection _collection = null!; + private MongoDbStore _mongoStore = null!; + + public async Task InitializeAsync() + { + try + { + await _container.StartAsync(); + _client = new MongoClient(_container.GetConnectionString()); + var database = _client.GetDatabase($"elsa-trydelete-{Guid.NewGuid():N}"); + _collection = database.GetCollection("key_values"); + _mongoStore = new MongoDbStore(_collection, _tenants); + } + catch + { + await DisposeAsync(); + throw; + } + } + + public async Task DisposeAsync() + { + try + { + _client?.Dispose(); + } + finally + { + await _container.DisposeAsync(); + } + } + + [Fact(DisplayName = "#260: two Mongo stores racing TryDeleteAsync: exactly one returns true")] + public async Task TryDeleteAsync_TwoNodes_ExactlyOneReturnsTrue() + { + using var tenant = _tenants.PushContext(Tenant.Default); + var nodeA = new MongoKeyValueStore(_mongoStore); + var nodeB = new MongoKeyValueStore(_mongoStore); + await nodeA.SaveAsync(Pair(LegacyKey, "legacy-maintenance"), CancellationToken.None); + + var results = await Task.WhenAll(nodeA.TryDeleteAsync(LegacyKey), nodeB.TryDeleteAsync(LegacyKey)); + + Assert.Equal(1, results.Count(won => won)); + Assert.Equal(1, results.Count(won => !won)); + Assert.Null(await nodeA.FindAsync(new KeyValueFilter { Key = LegacyKey }, CancellationToken.None)); + } + + [Fact(DisplayName = "#260: the default find-then-delete lets both racers win")] + public async Task DefaultTryDeleteAsync_TwoNodes_BothReturnTrue() + { + using var tenant = _tenants.PushContext(Tenant.Default); + var inner = new MongoKeyValueStore(_mongoStore); + await inner.SaveAsync(Pair(LegacyKey, "legacy-maintenance"), CancellationToken.None); + var dim = new BarrierFindThenDelete(inner); + + var results = await Task.WhenAll(dim.TryDeleteAsync(LegacyKey), dim.TryDeleteAsync(LegacyKey)); + + Assert.Equal(2, results.Count(won => won)); + Assert.Null(await inner.FindAsync(new KeyValueFilter { Key = LegacyKey }, CancellationToken.None)); + } + + [Fact(DisplayName = "#260: TryDeleteAsync returns false when the key is missing")] + public async Task TryDeleteAsync_NotFound_ReturnsFalse() + { + using var tenant = _tenants.PushContext(Tenant.Default); + var store = new MongoKeyValueStore(_mongoStore); + + Assert.False(await store.TryDeleteAsync(LegacyKey)); + Assert.False(await store.TryDeleteAsync(LegacyKey)); + } + + [Fact(DisplayName = "#260: a NULL TenantId legacy row is found and TryDeleted by the default tenant")] + public async Task DefaultTenant_FindsAndTryDeletesNullTenantIdRow() + { + await _collection.InsertOneAsync(new SerializedKeyValuePair + { + Key = LegacyKey, + SerializedValue = "legacy-null", + TenantId = null + }); + + using var tenant = _tenants.PushContext(Tenant.Default); + var store = new MongoKeyValueStore(_mongoStore); + + var found = await store.FindAsync(new KeyValueFilter { Key = LegacyKey }, CancellationToken.None); + Assert.Equal("legacy-null", found?.SerializedValue); + Assert.True(await store.TryDeleteAsync(LegacyKey)); + Assert.Null(await store.FindAsync(new KeyValueFilter { Key = LegacyKey }, CancellationToken.None)); + } + + private static SerializedKeyValuePair Pair(string key, string value) => new() + { + Key = key, + SerializedValue = value + }; + + private sealed class TestTenantAccessor : ITenantAccessor + { + public string TenantId => Tenant?.Id ?? Tenant.DefaultTenantId; + public Tenant? Tenant { get; private set; } + + public IDisposable PushContext(Tenant? tenant) + { + var previousTenant = Tenant; + Tenant = tenant; + return new Restore(() => Tenant = previousTenant); + } + + private sealed class Restore(Action restore) : IDisposable + { + public void Dispose() => restore(); + } + } + + /// + /// The core default TryDeleteAsync (find, then delete, always true if found). + /// Both Finds complete before either Delete so the race is deterministic. + /// + private sealed class BarrierFindThenDelete(IKeyValueStore inner) + { + private readonly TaskCompletionSource _bothFound = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _finds; + + public async Task TryDeleteAsync(string key) + { + var found = await inner.FindAsync(new KeyValueFilter { Key = key }, CancellationToken.None); + if (Interlocked.Increment(ref _finds) == 2) + _bothFound.TrySetResult(); + await _bothFound.Task; + if (found is null) + return false; + await inner.DeleteAsync(key, CancellationToken.None); + return true; + } + } +} diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValueStoreTryDeleteTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValueStoreTryDeleteTests.cs new file mode 100644 index 0000000000..4cc258aaea --- /dev/null +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValueStoreTryDeleteTests.cs @@ -0,0 +1,193 @@ +using Dapper; +using Elsa.Common.Multitenancy; +using Elsa.KeyValues.Contracts; +using Elsa.KeyValues.Entities; +using Elsa.KeyValues.Models; +using Elsa.Persistence.Dapper.Extensions; +using Elsa.Persistence.Dapper.Modules.Runtime.Records; +using Elsa.Persistence.Dapper.Modules.Runtime.Stores; +using Elsa.Persistence.Dapper.Services; +using FluentMigrator.Runner; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Persistence.Dapper.UnitTests; + +/// +/// Atomic TryDeleteAsync on a migration-built SQLite DB (#260). +/// There is no PostgreSQL Testcontainers setup on this branch. +/// Calls the Dapper store method directly; IKeyValueStore.TryDeleteAsync is not +/// in 3.10.0-preview.5722 yet (waiting for a core pin at or after c1c935ce). +/// +public sealed class DapperKeyValueStoreTryDeleteTests : IDisposable +{ + private const string LegacyKey = "elsa.quiescence.pause.default"; + private readonly string _databasePath = Path.Combine(Path.GetTempPath(), $"elsa-dapper-trydelete-{Guid.NewGuid():N}.db"); + private readonly string _connectionString; + private readonly TestTenantAccessor _tenants = new(); + + public DapperKeyValueStoreTryDeleteTests() + { + _connectionString = new SqliteConnectionStringBuilder { DataSource = _databasePath, Pooling = false }.ToString(); + } + + [Fact(DisplayName = "#260: two Dapper stores racing TryDeleteAsync: exactly one returns true")] + public async Task TryDeleteAsync_TwoNodes_ExactlyOneReturnsTrue() + { + MigrateUp(); + using var tenant = _tenants.PushContext(Tenant.Default); + var nodeA = CreateStore(); + var nodeB = CreateStore(); + await nodeA.SaveAsync(Pair(LegacyKey, "legacy-maintenance"), CancellationToken.None); + + var results = await Task.WhenAll(nodeA.TryDeleteAsync(LegacyKey), nodeB.TryDeleteAsync(LegacyKey)); + + Assert.Equal(1, results.Count(won => won)); + Assert.Equal(1, results.Count(won => !won)); + Assert.Null(await nodeA.FindAsync(new KeyValueFilter { Key = LegacyKey }, CancellationToken.None)); + } + + [Fact(DisplayName = "#260: the default find-then-delete lets both racers win")] + public async Task DefaultTryDeleteAsync_TwoNodes_BothReturnTrue() + { + MigrateUp(); + using var tenant = _tenants.PushContext(Tenant.Default); + var inner = CreateStore(); + await inner.SaveAsync(Pair(LegacyKey, "legacy-maintenance"), CancellationToken.None); + var dim = new BarrierFindThenDelete(inner); + + var results = await Task.WhenAll(dim.TryDeleteAsync(LegacyKey), dim.TryDeleteAsync(LegacyKey)); + + Assert.Equal(2, results.Count(won => won)); + Assert.Null(await inner.FindAsync(new KeyValueFilter { Key = LegacyKey }, CancellationToken.None)); + } + + [Fact(DisplayName = "#260: TryDeleteAsync returns false when the key is missing")] + public async Task TryDeleteAsync_NotFound_ReturnsFalse() + { + MigrateUp(); + using var tenant = _tenants.PushContext(Tenant.Default); + var store = CreateStore(); + + Assert.False(await store.TryDeleteAsync(LegacyKey)); + Assert.False(await store.TryDeleteAsync(LegacyKey)); + } + + [Fact(DisplayName = "#245 / #260: a NULL TenantId legacy row is found and TryDeleted by the default tenant")] + public async Task DefaultTenant_FindsAndTryDeletesNullTenantIdRow() + { + MigrateUp(); + using (var connection = new SqliteConnection(_connectionString)) + { + connection.Execute( + "insert into KeyValues (Id, TenantId, Value) values (@Id, null, @Value)", + new { Id = LegacyKey, Value = "legacy-null" }); + } + + using var tenant = _tenants.PushContext(Tenant.Default); + var store = CreateStore(); + + var found = await store.FindAsync(new KeyValueFilter { Key = LegacyKey }, CancellationToken.None); + Assert.Equal("legacy-null", found?.SerializedValue); + Assert.True(await store.TryDeleteAsync(LegacyKey)); + Assert.Null(await store.FindAsync(new KeyValueFilter { Key = LegacyKey }, CancellationToken.None)); + } + + [Fact(DisplayName = "#245 / #260: a named tenant cannot TryDelete a NULL TenantId default-tenant row")] + public async Task NamedTenant_DoesNotTryDeleteNullTenantIdRow() + { + MigrateUp(); + using (var connection = new SqliteConnection(_connectionString)) + { + connection.Execute( + "insert into KeyValues (Id, TenantId, Value) values (@Id, null, @Value)", + new { Id = LegacyKey, Value = "legacy-null" }); + } + + using (var named = _tenants.PushContext(new Tenant { Id = "tenant-a" })) + { + var namedStore = CreateStore(); + Assert.Null(await namedStore.FindAsync(new KeyValueFilter { Key = LegacyKey }, CancellationToken.None)); + Assert.False(await namedStore.TryDeleteAsync(LegacyKey)); + } + + using var tenant = _tenants.PushContext(Tenant.Default); + Assert.True(TableHasKey(LegacyKey)); + } + + public void Dispose() + { + File.Delete(_databasePath); + } + + private DapperKeyValueStore CreateStore() + { + var connection = new SqliteDbConnectionProvider(_connectionString); + return new DapperKeyValueStore(new Store(connection, _tenants, "KeyValues")); + } + + private void MigrateUp() + { + using var services = new ServiceCollection() + .AddFluentMigratorCore() + .ConfigureRunner(rb => rb + .AddSQLite() + .WithGlobalConnectionString(_connectionString) + .ScanIn(typeof(Elsa.Persistence.Dapper.Migrations.Runtime.Initial).Assembly).For.Migrations()) + .BuildServiceProvider(false); + using var scope = services.CreateScope(); + scope.ServiceProvider.GetRequiredService().MigrateUp(); + } + + private bool TableHasKey(string key) + { + using var connection = new SqliteConnection(_connectionString); + return connection.ExecuteScalar("select count(*) from KeyValues where Id = @key", new { key }) == 1; + } + + private static SerializedKeyValuePair Pair(string key, string value) => new() + { + Key = key, + SerializedValue = value + }; + + private sealed class TestTenantAccessor : ITenantAccessor + { + public string TenantId => Tenant?.Id ?? Tenant.DefaultTenantId; + public Tenant? Tenant { get; private set; } + + public IDisposable PushContext(Tenant? tenant) + { + var previousTenant = Tenant; + Tenant = tenant; + return new Restore(() => Tenant = previousTenant); + } + + private sealed class Restore(Action restore) : IDisposable + { + public void Dispose() => restore(); + } + } + + /// + /// The core default TryDeleteAsync (find, then delete, always true if found). + /// Both Finds complete before either Delete so the race is deterministic. + /// + private sealed class BarrierFindThenDelete(IKeyValueStore inner) + { + private readonly TaskCompletionSource _bothFound = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _finds; + + public async Task TryDeleteAsync(string key) + { + var found = await inner.FindAsync(new KeyValueFilter { Key = key }, CancellationToken.None); + if (Interlocked.Increment(ref _finds) == 2) + _bothFound.TrySetResult(); + await _bothFound.Task; + if (found is null) + return false; + await inner.DeleteAsync(key, CancellationToken.None); + return true; + } + } +} diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/ParameterizedQueryBuilderExtensionsTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/ParameterizedQueryBuilderExtensionsTests.cs index 5c2e260b97..ccb0a3d3bb 100644 --- a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/ParameterizedQueryBuilderExtensionsTests.cs +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/ParameterizedQueryBuilderExtensionsTests.cs @@ -99,6 +99,16 @@ public void StartsWith_BindsFieldPrefixedParameter() Assert.DoesNotContain("Id", query.Parameters.ParameterNames.Where(name => name == "Id")); } + [Fact(DisplayName = "IsNullOrEmpty matches NULL or empty string for the default tenant")] + public void IsNullOrEmpty_MatchesNullOrEmptyTenantId() + { + var query = new ParameterizedQuery(new SqliteDialect()) + .From("KeyValues") + .IsNullOrEmpty("TenantId"); + + Assert.Contains("and (TenantId is null or TenantId = '')", query.Sql.ToString(), StringComparison.Ordinal); + } + [Fact] public async Task In_WithTenantAgnosticFilter_ReturnsMatchingRowsAcrossTenants() { From 6ef1f5ffe1f9795d5696b856aa6e29e0053762be Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 28 Sep 2026 09:10:11 +0000 Subject: [PATCH 063/169] ci: retrigger pr workflow for #260 Co-authored-by: Sipke Schoorstra From 95c7047113efcab5f6ba9d4add2601cd9ae91f00 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 28 Sep 2026 09:11:09 +0000 Subject: [PATCH 064/169] ci: touch TryDelete tests so pr.yml paths match Co-authored-by: Sipke Schoorstra --- .../DapperKeyValueStoreTryDeleteTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValueStoreTryDeleteTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValueStoreTryDeleteTests.cs index 4cc258aaea..534cc92160 100644 --- a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValueStoreTryDeleteTests.cs +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValueStoreTryDeleteTests.cs @@ -17,7 +17,7 @@ namespace Elsa.Persistence.Dapper.UnitTests; /// Atomic TryDeleteAsync on a migration-built SQLite DB (#260). /// There is no PostgreSQL Testcontainers setup on this branch. /// Calls the Dapper store method directly; IKeyValueStore.TryDeleteAsync is not -/// in 3.10.0-preview.5722 yet (waiting for a core pin at or after c1c935ce). +/// in 3.10.0-preview.5722 yet (waiting for a core pin at or after c1c935ce / #8538). /// public sealed class DapperKeyValueStoreTryDeleteTests : IDisposable { From 81f9fb694273564c101eb52fb1d4a47af2e1df9b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 28 Sep 2026 09:16:18 +0000 Subject: [PATCH 065/169] fix(dapper): keep QuoteIdent with StartsWith binding after #259 rebase #259 quoted identifiers but still bound @{field} while emitting @SearchTermLike. Keep QuoteIdent and @{field}StartsWith together. Add PostgreSQL Testcontainers TryDelete coverage now that the infra is on the branch. Update the non-PG SQL snapshot for StartsWith. Co-authored-by: Sipke Schoorstra --- .../ParameterizedQueryBuilderExtensions.cs | 3 +- ...erKeyValueStoreTryDeletePostgreSqlTests.cs | 137 ++++++++++++++++++ .../DapperKeyValueStoreTryDeleteTests.cs | 2 +- .../NonPgQuerySqlSnapshotTests.cs | 2 +- ...arameterizedQueryBuilderExtensionsTests.cs | 14 ++ 5 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValueStoreTryDeletePostgreSqlTests.cs diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs index a41eb8964e..028d0d0c8a 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs @@ -160,7 +160,8 @@ public static ParameterizedQuery IsNull(this ParameterizedQuery query, string fi /// public static ParameterizedQuery IsNullOrEmpty(this ParameterizedQuery query, string field) { - query.Sql.AppendLine($"and ({field} is null or {field} = '')"); + var ident = query.QuoteIdent(field); + query.Sql.AppendLine($"and ({ident} is null or {ident} = '')"); return query; } diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValueStoreTryDeletePostgreSqlTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValueStoreTryDeletePostgreSqlTests.cs new file mode 100644 index 0000000000..1ccda52a5f --- /dev/null +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValueStoreTryDeletePostgreSqlTests.cs @@ -0,0 +1,137 @@ +using Dapper; +using Elsa.Common.Multitenancy; +using Elsa.KeyValues.Contracts; +using Elsa.KeyValues.Entities; +using Elsa.KeyValues.Models; +using Elsa.Persistence.Dapper.Modules.Runtime.Records; +using Elsa.Persistence.Dapper.Modules.Runtime.Stores; +using Elsa.Persistence.Dapper.Services; +using FluentMigrator.Runner; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; +using Testcontainers.PostgreSql; + +namespace Elsa.Persistence.Dapper.UnitTests; + +/// +/// Same #260 TryDelete cases as SQLite, on a fresh migration-built PostgreSQL via Testcontainers. +/// Calls the Dapper store method directly; IKeyValueStore.TryDeleteAsync is not +/// in 3.10.0-preview.5722 yet (waiting for a core pin at or after c1c935ce / #8538). +/// +public sealed class DapperKeyValueStoreTryDeletePostgreSqlTests : IAsyncLifetime +{ + private const string LegacyKey = "elsa.quiescence.pause.default"; + private readonly PostgreSqlContainer _container = new PostgreSqlBuilder() + .WithImage("postgres:16") + .WithDatabase("elsa") + .WithUsername("elsa") + .WithPassword("elsa") + .Build(); + private readonly TestTenantAccessor _tenants = new(); + private string _connectionString = null!; + + public async Task InitializeAsync() + { + try + { + await _container.StartAsync(); + _connectionString = _container.GetConnectionString(); + } + catch + { + await DisposeAsync(); + throw; + } + } + + public async Task DisposeAsync() + { + await _container.DisposeAsync(); + } + + [Fact(DisplayName = "#260 PG: two Dapper stores racing TryDeleteAsync: exactly one returns true")] + public async Task TryDeleteAsync_TwoNodes_ExactlyOneReturnsTrue() + { + MigrateUp(); + using var tenant = _tenants.PushContext(Tenant.Default); + var nodeA = CreateStore(); + var nodeB = CreateStore(); + await nodeA.SaveAsync(Pair(LegacyKey, "legacy-maintenance"), CancellationToken.None); + + var results = await Task.WhenAll(nodeA.TryDeleteAsync(LegacyKey), nodeB.TryDeleteAsync(LegacyKey)); + + Assert.Equal(1, results.Count(won => won)); + Assert.Equal(1, results.Count(won => !won)); + Assert.Null(await nodeA.FindAsync(new KeyValueFilter { Key = LegacyKey }, CancellationToken.None)); + } + + [Fact(DisplayName = "#260 PG: TryDeleteAsync returns false when the key is missing")] + public async Task TryDeleteAsync_NotFound_ReturnsFalse() + { + MigrateUp(); + using var tenant = _tenants.PushContext(Tenant.Default); + var store = CreateStore(); + + Assert.False(await store.TryDeleteAsync(LegacyKey)); + } + + [Fact(DisplayName = "#245 / #260 PG: a NULL TenantId legacy row is found and TryDeleted by the default tenant")] + public async Task DefaultTenant_FindsAndTryDeletesNullTenantIdRow() + { + MigrateUp(); + using (var connection = new NpgsqlConnection(_connectionString)) + { + connection.Execute( + """insert into "KeyValues" ("Id", "TenantId", "Value") values (@Id, null, @Value)""", + new { Id = LegacyKey, Value = "legacy-null" }); + } + + using var tenant = _tenants.PushContext(Tenant.Default); + var store = CreateStore(); + + var found = await store.FindAsync(new KeyValueFilter { Key = LegacyKey }, CancellationToken.None); + Assert.Equal("legacy-null", found?.SerializedValue); + Assert.True(await store.TryDeleteAsync(LegacyKey)); + Assert.Null(await store.FindAsync(new KeyValueFilter { Key = LegacyKey }, CancellationToken.None)); + } + + private DapperKeyValueStore CreateStore() => + new(new Store(new PostgreSqlDbConnectionProvider(_connectionString), _tenants, "KeyValues")); + + private void MigrateUp() + { + using var services = new ServiceCollection() + .AddFluentMigratorCore() + .ConfigureRunner(rb => rb + .AddPostgres() + .WithGlobalConnectionString(_connectionString) + .ScanIn(typeof(Elsa.Persistence.Dapper.Migrations.Runtime.Initial).Assembly).For.Migrations()) + .BuildServiceProvider(false); + using var scope = services.CreateScope(); + scope.ServiceProvider.GetRequiredService().MigrateUp(); + } + + private static SerializedKeyValuePair Pair(string key, string value) => new() + { + Key = key, + SerializedValue = value + }; + + private sealed class TestTenantAccessor : ITenantAccessor + { + public string TenantId => Tenant?.Id ?? Tenant.DefaultTenantId; + public Tenant? Tenant { get; private set; } + + public IDisposable PushContext(Tenant? tenant) + { + var previousTenant = Tenant; + Tenant = tenant; + return new Restore(() => Tenant = previousTenant); + } + + private sealed class Restore(Action restore) : IDisposable + { + public void Dispose() => restore(); + } + } +} diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValueStoreTryDeleteTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValueStoreTryDeleteTests.cs index 534cc92160..5085ab94e9 100644 --- a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValueStoreTryDeleteTests.cs +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValueStoreTryDeleteTests.cs @@ -15,7 +15,7 @@ namespace Elsa.Persistence.Dapper.UnitTests; /// /// Atomic TryDeleteAsync on a migration-built SQLite DB (#260). -/// There is no PostgreSQL Testcontainers setup on this branch. +/// PostgreSQL coverage is in . /// Calls the Dapper store method directly; IKeyValueStore.TryDeleteAsync is not /// in 3.10.0-preview.5722 yet (waiting for a core pin at or after c1c935ce / #8538). /// diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/NonPgQuerySqlSnapshotTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/NonPgQuerySqlSnapshotTests.cs index fecd4a1c21..49ee90cc8c 100644 --- a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/NonPgQuerySqlSnapshotTests.cs +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/NonPgQuerySqlSnapshotTests.cs @@ -185,7 +185,7 @@ private static void AssertSnapshots(ISqlDialect dialect, IReadOnlyDictionary("IdStartsWith")); + } + [Fact] public async Task In_WithTenantAgnosticFilter_ReturnsMatchingRowsAcrossTenants() { From 3f110a2afbb613efb4f70b4b464540b962459a42 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 28 Sep 2026 09:23:47 +0000 Subject: [PATCH 066/169] test(dapper): fail if StartsWith drops QuoteIdent on PostgreSQL CR recommended: pin the quoted @NameStartsWith shape so taking this PR's unquoted StartsWith side fails a test, not only a local PG harness. Co-authored-by: Sipke Schoorstra --- .../Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs index 8bf7c1d4fd..1a92eada04 100644 --- a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs @@ -68,6 +68,7 @@ public void QueryBuilder_QuotesInlinedIdentifiers() Assert.Contains("and \"IsLatest\" = true", queries["version-latest"], StringComparison.Ordinal); Assert.Contains("and \"IsPublished\" = false", queries["version-draft"], StringComparison.Ordinal); Assert.Contains("\"Name\" like @SearchTermLike", queries["definition-search"], StringComparison.Ordinal); + Assert.Contains("and \"Name\" like @NameStartsWith", queries["starts-with"], StringComparison.Ordinal); Assert.Contains("\"Id\" like @SearchTerm", queries["instance-search"], StringComparison.Ordinal); Assert.DoesNotContain("\"ID\"", queries["instance-search"], StringComparison.Ordinal); Assert.Contains("and \"Id\" in (", queries["paged-delete"], StringComparison.Ordinal); From fa7ad8f1daae65335a27c22f53032c9f31bf1921 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Tue, 29 Sep 2026 05:48:23 +0200 Subject: [PATCH 067/169] 3.9 identity & access polish: API client collision, permission-gated navigation, sign-out, sign-in feedback, role editor fixes (#1075) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(external-auth): stop Studio API clients colliding with core client names Elsa.Api.Client names each Refit HttpClient after the API's simple type name and resolves the host's authentication handler as a scoped service. Its default clients already register IExternalAuthenticationConnectionsApi and IExternalIdentityLinksApi, so the Studio-local contracts with the same simple names shared those named clients and added the same handler instance twice. Every call from the connections and identity-links pages then failed with "DelegatingHandler instances provided to HttpMessageHandlerBuilder must not be reused or cached". Rename the Studio-local contracts to IExternalAuthenticationConnectionManagementApi and IExternalIdentityLinkManagementApi, and add a regression test that builds the handler pipeline for every module API alongside the default clients. Co-Authored-By: Claude Opus 5.5 * fix(security): separate "Direct grant" indicator from permission label The role editor's exact-permission options pulled the "Direct grant" caption 0.2rem to the left with a negative margin, and the option row had no gap. MudBlazor's checkbox label has no trailing space, so the caption touched or overlapped the verb label ("writeDirect grant"). Drop the negative margin, give the option row a column gap, reset the "Covered by" chip's default margin so both indicators get the same spacing, and keep the caption on one line so it wraps below the checkbox as a unit when the column is too narrow. Co-Authored-By: Claude Opus 5.5 * fix(security): label the category bulk action "Select all" The tab intro already explains that category actions add exact grants only, so the "exact" qualifier on the button read oddly. Co-Authored-By: Claude Opus 5.5 * feat(security): edit advanced grants in place Advanced grant rows gain an edit (pencil) action next to delete. Editing switches the row to an inline text field with Save/Cancel (Enter saves, Escape cancels); only one row is editable at a time and the action is hidden for read-only roles, like delete. Add and edit now share one validator (trim, pattern validity, wildcard required, duplicate detection). Saving an edit replaces the grant at the same position and reloads its reach; saving it unchanged is a no-op. Adding a duplicate now reports an error instead of silently no-oping. Co-Authored-By: Claude Opus 5.5 * feat(elsa-identity): add sign-out to the app bar The username/password (ElsaIdentity) provider had no way to sign out from Studio; only the ExternalAuthentication broker and legacy ElsaLogin contributed a user menu to the app bar. - Extract the shared app bar user menu shell (auth-state subscription + MudMenu with the user's name) into Elsa.Studio.Shared as UserMenu, and rebuild the broker (Server/Wasm) and legacy ElsaLogin menus on it. - Add ISignOutService/ElsaIdentitySignOutService: clears the stored access, refresh and id tokens, publishes the anonymous auth state and force-loads /login. Elsa Identity has no revocation endpoint, so sign-out is local. - Register ElsaIdentityUIFeature from AddElsaIdentityUI so it contributes ElsaIdentityUserMenu ("Sign out") to the app bar for Server and Wasm. - Share token clearing with JwtTokenProvider via IJwtAccessor.ClearTokensAsync. Co-Authored-By: Claude Opus 5.5 * fix(auth-ui): show busy state while signing in Submitting the login form gave no feedback while the sign-in request was in flight. The Elsa account forms (Elsa Identity and the browser broker local method) and the identity-provider buttons now go busy before their first await: the button is disabled, shows a spinner and "Signing in…", the credential fields are disabled, and repeat clicks are ignored. Busy state is always reset in a finally block, so failures surface exactly as before and the form can be retried. Adds a small shared BusyButtonContent component and bUnit coverage using pending TaskCompletionSource-backed sign-ins. Co-Authored-By: Claude Opus 5.5 * fix(auth-ui): keep the sign-in button busy while a successful sign-in redirects Resetting the busy state in finally re-enabled the button for a moment before the full-page redirect unloaded the page, which undid the visual cue. Co-Authored-By: Claude Opus 5.5 * feat(core): filter navigation by the current user's permissions Add a Studio-side permission model that mirrors the backend grammar ({resource}:{verb}, bare "*", "resource/*" subtrees and "*" verbs), an IPermissionService that reads Elsa's "permissions" claims from the authentication state, and MenuItem.RequiredPermissions. DefaultMenuService now drops items the user lacks permissions for and parents left without visible children, without mutating provider-owned items. Fails open: when there is no permission information at all (no auth state, anonymous user, or a token without any "permissions" claims, such as a third-party OIDC token) every check passes and the menu is unchanged. Co-Authored-By: Claude Opus 5.5 * feat(shell): render an access-denied state for pages the user cannot use Pages declare [RequirePermission(resource, verb)]. The shell cascades the current RouteData and MainLayout wraps the body in PermissionPageGuard, which resolves permissions only for gated pages and renders AccessDenied (listing the missing permissions) instead of instantiating the page, so no API call is issued and no 403 toast appears. Ungated pages render without a permission lookup; unknown permissions keep today's behavior. Co-Authored-By: Claude Opus 5.5 * feat(modules): declare required permissions on built-in navigation and pages Map each built-in nav item and its pages to the backend permission its initial API calls require (from the Core 3.9 endpoint declarations): dashboard:view, workflows/definitions:view, workflows/instances:view, user-tasks:view, alterations:view/execute, secrets:view, labels:view, ai/capabilities:view + ai/tools:view + ai/chat:execute, diagnostics/{console-logs,structured-logs,opentelemetry}:view, and identity/users:view / identity/roles:view on the Users and Roles entries. Co-Authored-By: Claude Opus 5.5 * test(shell): check the shared account menu for the utility-colour contract The account menus now share UserMenu.razor, so the contract is asserted there instead of in each provider component. Co-Authored-By: Claude Opus 5.5 * fix(external-auth): gate UI on core 3.9 permission names and wildcards Studio compared the permissions claims verbatim against pre-3.9 names such as external-authentication:connections:read, while core 3.9 issues external-authentication/connections:view and wildcard grants. Only users holding a bare "*" ever saw the connections, identity-links and sessions pages. Map the constants to the core 3.9 resources and verbs, and match claims with the shared PermissionMatcher so resource and verb wildcards count. The service still fails closed when the token carries no permissions. Co-Authored-By: Claude Opus 5.5 * feat(security): flag redundant exact grants and apply open grant edits on save A ticked checkbox already says a permission is granted exactly, so the "Direct grant" caption only repeated it. The case the checkbox cannot show is an exact grant that a wildcard already covers; those rows now say "Also covered by ", matching the duplicate warning on the Advanced grants tab. Saving the role while an advanced grant edit is open used to drop the typed value. The draft now lives in an AdvancedGrantEdit owned by the surface, and Save changes applies it first; an invalid draft keeps the edit open with its error, switches to the Advanced grants tab and does not save. Co-Authored-By: Claude Opus 5.5 * fix(elsa-identity): keep an in-flight token refresh from undoing sign-out A refresh awaiting its response stored the new tokens unconditionally, so a sign-out that cleared them in the meantime (in this tab or another) was silently reversed. Only store the response if the refresh token that was sent is still the current one. Co-Authored-By: Claude Opus 5.5 * fix(security): keep advanced grant drafts when switching rows and clear stale errors Starting an edit on another grant now applies the open one first and stays on it when its draft is invalid, instead of discarding what was typed. Changing the draft clears the previous validation error. Co-Authored-By: Claude Opus 5.5 * fix(shell): re-evaluate page and menu permissions when authentication changes The page guard and navigation menu decided once, so claims that changed while a page stayed mounted left revoked pages visible and newly granted items hidden until a reload. Both now re-evaluate on AuthenticationStateChanged; the guard swaps in the new decision without unmounting a still-permitted page. Co-Authored-By: Claude Opus 5.5 * fix(elsa-identity): serialize sign-out with a refresh storing its tokens Checking the stored refresh token and then writing left await gaps in which sign-out could clear the session, only for the remaining writes to restore part of it. A scoped ElsaIdentitySessionGate now serializes sign-out's clear with the refresh's check-and-store; the network request stays outside the gate, and the token comparison still covers sign-out from another tab. Co-Authored-By: Claude Opus 5.5 * fix(shell): ignore outdated permission and menu evaluations Overlapping authentication changes could resolve out of order and leave an older decision in place. The page guard and menu now apply only their most recent evaluation. Co-Authored-By: Claude Opus 5.5 * fix(elsa-identity): let a stale refresh defer to a newer stored session When the stored refresh token changed while a refresh was in flight (another tab signed in or refreshed), the stale response was rejected but reported as a failure, so JwtTokenProvider cleared storage and signed the newer session out. After the request, defer to what is stored: fail only if the session was cleared, otherwise report the stored tokens without writing. A 401 from a superseded refresh token now follows the same rule. Co-Authored-By: Claude Opus 5.5 * docs(external-auth): use the renamed management API contracts in the architecture doc Co-Authored-By: Claude Opus 5.5 * refactor(auth-ui): share the sign-in busy protocol through BusyState Both local sign-in forms and the provider button hand-rolled the same guard: ignore repeat clicks, go busy before the first await, and stay busy when the sign-in navigates away. BusyState in Shared now owns it. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- doc/AUTHENTICATION_ARCHITECTURE.md | 4 +- .../ClaimsPermissionServiceTests.cs | 88 +++++++ .../Authorization/DefaultMenuServiceTests.cs | 121 +++++++++ .../Authorization/NavMenuTests.cs | 61 +++++ .../NotifyingAuthenticationStateProvider.cs | 14 + .../Authorization/PermissionMatcherTests.cs | 46 ++++ .../Authorization/PermissionPageGuardTests.cs | 107 ++++++++ .../Authorization/PermissionTests.cs | 36 +++ .../Authorization/StubPermissionService.cs | 25 ++ .../Elsa.Studio.Core.Tests.csproj | 2 + .../StudioShellCssContractTests.cs | 8 +- .../Authorization/ClaimsPermissionService.cs | 44 ++++ .../Authorization/IPermissionService.cs | 10 + .../Authorization/Permission.cs | 64 +++++ .../Authorization/PermissionMatcher.cs | 41 +++ .../Authorization/PermissionVerbs.cs | 23 ++ .../RequirePermissionAttribute.cs | 19 ++ .../Authorization/UserPermissions.cs | 38 +++ .../Extensions/ServiceCollectionExtensions.cs | 4 + .../Elsa.Studio.Core/Models/MenuItem.cs | 14 + .../Services/DefaultMenuService.cs | 39 ++- .../Components/AccessDenied.razor | 16 ++ .../Components/AppBar/UserMenu.razor | 42 +++ .../Components/BusyButtonContent.razor | 21 ++ .../Components/BusyState.cs | 32 +++ .../Components/NavMenu.razor | 49 +++- .../Components/PermissionPageGuard.razor | 90 +++++++ .../Layouts/MainLayout.razor | 4 +- src/framework/Elsa.Studio.Shell/App.razor | 43 +-- src/modules/Elsa.Studio.AI/AIPermissions.cs | 14 + src/modules/Elsa.Studio.AI/Menu/WeaverMenu.cs | 4 +- .../Elsa.Studio.AI/UI/Pages/Weaver.razor | 3 + src/modules/Elsa.Studio.AI/_Imports.razor | 1 + .../AlterationPermissions.cs | 8 + .../Menu/AlterationsMenu.cs | 4 + .../Elsa.Studio.Alterations/Pages/Index.razor | 2 + .../Pages/Instances/Edit.razor | 3 + .../Pages/Instances/Index.razor | 2 + .../Pages/Plans/Details.razor | 1 + .../Elsa.Studio.Alterations/_Imports.razor | 2 + .../Components/ElsaIdentityLoginMethod.razor | 21 +- .../Components/ElsaIdentityUserMenu.razor | 6 + .../ElsaIdentityUIFeature.cs | 14 +- .../Extensions/ServiceCollectionExtensions.cs | 3 +- .../README.md | 2 +- .../Contracts/ISignOutService.cs | 12 + .../Extensions/JwtAccessorExtensions.cs | 19 ++ .../Extensions/ServiceCollectionExtensions.cs | 2 + .../ElsaIdentityRefreshTokenService.cs | 46 ++-- .../Services/ElsaIdentitySessionGate.cs | 27 ++ .../Services/ElsaIdentitySignOutService.cs | 39 +++ .../Services/JwtTokenProvider.cs | 9 +- .../TokenNames.cs | 6 + ...Elsa.Studio.Authentication.UI.Tests.csproj | 6 + .../ElsaIdentityLoginMethodTests.cs | 146 +++++++++++ .../Components/LoginMethodButton.razor | 19 +- .../DashboardPermissions.cs | 8 + .../Menu/DashboardMenu.cs | 4 +- .../Elsa.Studio.Dashboard/Pages/Index.razor | 1 + .../Elsa.Studio.Dashboard/_Imports.razor | 1 + .../ConsoleLogsPermissions.cs | 8 + .../Menu/ConsoleLogsMenu.cs | 4 +- .../UI/Pages/ConsoleLogs.razor | 1 + .../_Imports.razor | 1 + .../Menu/OpenTelemetryMenu.cs | 4 +- .../OpenTelemetryPermissions.cs | 8 + .../UI/Pages/OpenTelemetry.razor | 1 + .../_Imports.razor | 1 + .../Menu/StructuredLogsMenu.cs | 4 +- .../StructuredLogsPermissions.cs | 8 + .../UI/Pages/StructuredLogs.razor | 1 + .../_Imports.razor | 1 + .../Components/BrokerLoginState.razor | 39 +-- .../Components/BrokerLoginState.razor | 47 +--- .../ApiClientRegistrationTests.cs | 53 ++++ .../Compatibility/ElsaIdentitySignOutTests.cs | 229 ++++++++++++++++ .../Connections/ConnectionEditorTests.cs | 4 +- ...Studio.ExternalAuthentication.Tests.csproj | 1 + .../Links/ExternalIdentityLinksTests.cs | 8 +- .../Login/LoginChooserTests.cs | 86 +++++- ...nalAuthenticationPermissionServiceTests.cs | 48 ++++ ...lAuthenticationConnectionManagementApi.cs} | 2 +- ... => IExternalIdentityLinkManagementApi.cs} | 2 +- .../IdentityLinks/IdentityLinkDialog.razor | 6 +- .../LoginMethods/BrokerLocalLoginMethod.razor | 27 +- .../Extensions/ServiceCollectionExtensions.cs | 4 +- .../Models/ConnectionModels.cs | 34 ++- .../Pages/Connections/Edit.razor | 30 +-- .../Pages/Connections/Index.razor | 8 +- .../Pages/IdentityLinks/Index.razor | 8 +- .../Services/ConnectionActivationWorkflow.cs | 2 +- ...ExternalAuthenticationPermissionService.cs | 9 +- .../Elsa.Studio.Labels/LabelPermissions.cs | 8 + .../Elsa.Studio.Labels/Menu/LabelsMenu.cs | 4 +- .../Elsa.Studio.Labels/UI/Pages/Label.razor | 1 + .../Elsa.Studio.Labels/UI/Pages/Labels.razor | 1 + src/modules/Elsa.Studio.Labels/_Imports.razor | 1 + .../Components/LoginState.razor | 42 +-- .../Elsa.Studio.Secrets/Menu/SecretsMenu.cs | 4 +- .../Elsa.Studio.Secrets/Pages/Secret.razor | 1 + .../Elsa.Studio.Secrets/Pages/Secrets.razor | 1 + .../Elsa.Studio.Secrets/SecretsPermissions.cs | 8 + .../Elsa.Studio.Secrets/_Imports.razor | 1 + .../RoleEditorSurfaceTests.cs | 246 +++++++++++++++++- .../Components/RoleAdvancedGrantCard.razor | 73 +++++- .../Components/RoleEditorSurface.razor | 118 +++++++-- .../Menu/IdentitySecurityMenuContributor.cs | 8 +- .../Models/AdvancedGrantEdit.cs | 17 ++ .../Menu/UserTasksMenu.cs | 4 +- .../Pages/UserTask.razor | 1 + .../Pages/UserTasks.razor | 1 + .../UserTaskPermissions.cs | 8 + .../Elsa.Studio.UserTasks/_Imports.razor | 1 + .../WorkflowPermissions.cs | 11 + .../Menu/WorkflowsMenu.cs | 7 +- .../WorkflowDefinitions/Edit/Index.razor | 1 + .../WorkflowDefinitions/List/Index.razor | 1 + .../Pages/WorkflowInstances/List/Index.razor | 1 + .../Pages/WorkflowInstances/View/Index.razor | 2 + .../Elsa.Studio.Workflows/_Imports.razor | 1 + 120 files changed, 2503 insertions(+), 299 deletions(-) create mode 100644 src/framework/Elsa.Studio.Core.Tests/Authorization/ClaimsPermissionServiceTests.cs create mode 100644 src/framework/Elsa.Studio.Core.Tests/Authorization/DefaultMenuServiceTests.cs create mode 100644 src/framework/Elsa.Studio.Core.Tests/Authorization/NavMenuTests.cs create mode 100644 src/framework/Elsa.Studio.Core.Tests/Authorization/NotifyingAuthenticationStateProvider.cs create mode 100644 src/framework/Elsa.Studio.Core.Tests/Authorization/PermissionMatcherTests.cs create mode 100644 src/framework/Elsa.Studio.Core.Tests/Authorization/PermissionPageGuardTests.cs create mode 100644 src/framework/Elsa.Studio.Core.Tests/Authorization/PermissionTests.cs create mode 100644 src/framework/Elsa.Studio.Core.Tests/Authorization/StubPermissionService.cs create mode 100644 src/framework/Elsa.Studio.Core/Authorization/ClaimsPermissionService.cs create mode 100644 src/framework/Elsa.Studio.Core/Authorization/IPermissionService.cs create mode 100644 src/framework/Elsa.Studio.Core/Authorization/Permission.cs create mode 100644 src/framework/Elsa.Studio.Core/Authorization/PermissionMatcher.cs create mode 100644 src/framework/Elsa.Studio.Core/Authorization/PermissionVerbs.cs create mode 100644 src/framework/Elsa.Studio.Core/Authorization/RequirePermissionAttribute.cs create mode 100644 src/framework/Elsa.Studio.Core/Authorization/UserPermissions.cs create mode 100644 src/framework/Elsa.Studio.Shared/Components/AccessDenied.razor create mode 100644 src/framework/Elsa.Studio.Shared/Components/AppBar/UserMenu.razor create mode 100644 src/framework/Elsa.Studio.Shared/Components/BusyButtonContent.razor create mode 100644 src/framework/Elsa.Studio.Shared/Components/BusyState.cs create mode 100644 src/framework/Elsa.Studio.Shared/Components/PermissionPageGuard.razor create mode 100644 src/modules/Elsa.Studio.AI/AIPermissions.cs create mode 100644 src/modules/Elsa.Studio.Alterations/AlterationPermissions.cs create mode 100644 src/modules/Elsa.Studio.Authentication.ElsaIdentity.UI/Components/ElsaIdentityUserMenu.razor create mode 100644 src/modules/Elsa.Studio.Authentication.ElsaIdentity/Contracts/ISignOutService.cs create mode 100644 src/modules/Elsa.Studio.Authentication.ElsaIdentity/Extensions/JwtAccessorExtensions.cs create mode 100644 src/modules/Elsa.Studio.Authentication.ElsaIdentity/Services/ElsaIdentitySessionGate.cs create mode 100644 src/modules/Elsa.Studio.Authentication.ElsaIdentity/Services/ElsaIdentitySignOutService.cs create mode 100644 src/modules/Elsa.Studio.Authentication.UI.Tests/ElsaIdentityLoginMethodTests.cs create mode 100644 src/modules/Elsa.Studio.Dashboard/DashboardPermissions.cs create mode 100644 src/modules/Elsa.Studio.Diagnostics.ConsoleLogs/ConsoleLogsPermissions.cs create mode 100644 src/modules/Elsa.Studio.Diagnostics.OpenTelemetry/OpenTelemetryPermissions.cs create mode 100644 src/modules/Elsa.Studio.Diagnostics.StructuredLogs/StructuredLogsPermissions.cs create mode 100644 src/modules/Elsa.Studio.ExternalAuthentication.Tests/Compatibility/ApiClientRegistrationTests.cs create mode 100644 src/modules/Elsa.Studio.ExternalAuthentication.Tests/Compatibility/ElsaIdentitySignOutTests.cs create mode 100644 src/modules/Elsa.Studio.ExternalAuthentication.Tests/Permissions/ExternalAuthenticationPermissionServiceTests.cs rename src/modules/Elsa.Studio.ExternalAuthentication/Client/{IExternalAuthenticationConnectionsApi.cs => IExternalAuthenticationConnectionManagementApi.cs} (98%) rename src/modules/Elsa.Studio.ExternalAuthentication/Client/{IExternalIdentityLinksApi.cs => IExternalIdentityLinkManagementApi.cs} (95%) create mode 100644 src/modules/Elsa.Studio.Labels/LabelPermissions.cs create mode 100644 src/modules/Elsa.Studio.Secrets/SecretsPermissions.cs create mode 100644 src/modules/Elsa.Studio.Security/Models/AdvancedGrantEdit.cs create mode 100644 src/modules/Elsa.Studio.UserTasks/UserTaskPermissions.cs create mode 100644 src/modules/Elsa.Studio.Workflows.Core/WorkflowPermissions.cs diff --git a/doc/AUTHENTICATION_ARCHITECTURE.md b/doc/AUTHENTICATION_ARCHITECTURE.md index 18af5025f8..acda9393bd 100644 --- a/doc/AUTHENTICATION_ARCHITECTURE.md +++ b/doc/AUTHENTICATION_ARCHITECTURE.md @@ -746,9 +746,9 @@ Options/ ``` Client/ ├── IExternalAuthenticationBrokerApi.cs # authorize / token / logout -├── IExternalAuthenticationConnectionsApi.cs +├── IExternalAuthenticationConnectionManagementApi.cs ├── IExternalAuthenticationOperationsApi.cs -├── IExternalIdentityLinksApi.cs +├── IExternalIdentityLinkManagementApi.cs └── IIdentityRolesApi.cs # (ILoginMethodsApi lives alongside the broker API) Models/ diff --git a/src/framework/Elsa.Studio.Core.Tests/Authorization/ClaimsPermissionServiceTests.cs b/src/framework/Elsa.Studio.Core.Tests/Authorization/ClaimsPermissionServiceTests.cs new file mode 100644 index 0000000000..e1a92a1b09 --- /dev/null +++ b/src/framework/Elsa.Studio.Core.Tests/Authorization/ClaimsPermissionServiceTests.cs @@ -0,0 +1,88 @@ +using System.Security.Claims; +using Elsa.Studio.Authorization; +using Microsoft.AspNetCore.Components.Authorization; +using Xunit; + +namespace Elsa.Studio.Core.Tests.Authorization; + +public class ClaimsPermissionServiceTests +{ + private static readonly Permission SecretsView = new("secrets", "view"); + private static readonly Permission WorkflowInstancesView = new("workflows/instances", "view"); + + [Fact] + public async Task WithoutAnAuthenticationStateProvider_PermissionsAreUnknownAndEveryCheckPasses() + { + var permissions = await new ClaimsPermissionService().GetPermissionsAsync(); + + Assert.False(permissions.IsKnown); + Assert.True(permissions.Has(WorkflowInstancesView)); + } + + [Fact] + public async Task ForAnAnonymousUser_PermissionsAreUnknown() + { + var permissions = await GetPermissionsAsync(new ClaimsIdentity()); + + Assert.False(permissions.IsKnown); + } + + [Fact] + public async Task WhenTheTokenCarriesNoPermissionClaims_PermissionsAreUnknownAndEveryCheckPasses() + { + var permissions = await GetPermissionsAsync(Authenticated(new Claim("sub", "alice"))); + + Assert.False(permissions.IsKnown); + Assert.True(permissions.Has(WorkflowInstancesView)); + } + + [Fact] + public async Task WithPermissionClaims_OnlyGrantedPermissionsPass() + { + var permissions = await GetPermissionsAsync(Authenticated(Grant("secrets:view"), Grant("secrets:write"))); + + Assert.True(permissions.IsKnown); + Assert.True(permissions.Has(SecretsView)); + Assert.False(permissions.Has(WorkflowInstancesView)); + Assert.Equal([WorkflowInstancesView], permissions.GetMissing([SecretsView, WorkflowInstancesView])); + } + + [Fact] + public async Task TheWildcardGrant_PassesEveryCheck() + { + var permissions = await GetPermissionsAsync(Authenticated(Grant("*"))); + + Assert.True(permissions.HasAll([SecretsView, WorkflowInstancesView, new("identity/roles", "delete")])); + } + + [Fact] + public async Task MalformedGrants_AreSkippedWithoutDenyingTheRest() + { + var permissions = await GetPermissionsAsync(Authenticated(Grant("read:workflow-definitions:legacy"), Grant("workflows/*:view"))); + + Assert.True(permissions.IsKnown); + Assert.True(permissions.Has(WorkflowInstancesView)); + Assert.False(permissions.Has(SecretsView)); + } + + [Fact] + public async Task OnlyMalformedGrants_AreKnownAndDenyEverything() + { + var permissions = await GetPermissionsAsync(Authenticated(Grant("external-authentication:connections:read"))); + + Assert.True(permissions.IsKnown); + Assert.False(permissions.Has(SecretsView)); + } + + private static Task GetPermissionsAsync(ClaimsIdentity identity) => + new ClaimsPermissionService(new StaticAuthenticationStateProvider(identity)).GetPermissionsAsync().AsTask(); + + private static ClaimsIdentity Authenticated(params Claim[] claims) => new(claims, "test"); + + private static Claim Grant(string permission) => new(ClaimsPermissionService.ClaimType, permission); + + private sealed class StaticAuthenticationStateProvider(ClaimsIdentity identity) : AuthenticationStateProvider + { + public override Task GetAuthenticationStateAsync() => Task.FromResult(new AuthenticationState(new(identity))); + } +} diff --git a/src/framework/Elsa.Studio.Core.Tests/Authorization/DefaultMenuServiceTests.cs b/src/framework/Elsa.Studio.Core.Tests/Authorization/DefaultMenuServiceTests.cs new file mode 100644 index 0000000000..c9af22e39c --- /dev/null +++ b/src/framework/Elsa.Studio.Core.Tests/Authorization/DefaultMenuServiceTests.cs @@ -0,0 +1,121 @@ +using Elsa.Studio.Authorization; +using Elsa.Studio.Contracts; +using Elsa.Studio.Models; +using Elsa.Studio.Services; +using Xunit; + +namespace Elsa.Studio.Core.Tests.Authorization; + +/// +/// Uses a navigation shaped like the built-in modules' so the scenarios read like the reported one: a user whose +/// only role grants Secrets permissions. +/// +public class DefaultMenuServiceTests +{ + private readonly MenuItem _dashboard = Item("Dashboard", "", new Permission("dashboard", "view")); + private readonly MenuItem _definitions = Item("Definitions", "workflows/definitions", new Permission("workflows/definitions", "view")); + private readonly MenuItem _instances = Item("Instances", "workflows/instances", new Permission("workflows/instances", "view")); + private readonly MenuItem _secrets = Item("Secrets", "security/secrets", new Permission("secrets", "view")); + private readonly MenuItem _webhooks = Item("Webhooks", "webhooks"); + private readonly MenuItem _workflows; + + public DefaultMenuServiceTests() + { + _workflows = Item("Workflows", ""); + _workflows.SubMenuItems.Add(_definitions); + _workflows.SubMenuItems.Add(_instances); + } + + [Fact] + public async Task ASecretsOnlyUser_SeesOnlySecretsAndUngatedItems() + { + var items = await GetMenuItemsAsync(new StubPermissionService("secrets:view", "secrets:write", "secrets:delete")); + + Assert.Equal(["Secrets", "Webhooks"], items.Select(x => x.Text).Order()); + } + + [Fact] + public async Task AParentWhoseChildrenAreAllHidden_IsHidden() + { + var items = await GetMenuItemsAsync(new StubPermissionService("dashboard:view")); + + Assert.DoesNotContain(items, x => x.Text == "Workflows"); + } + + [Fact] + public async Task AParent_KeepsOnlyItsPermittedChildren() + { + var items = await GetMenuItemsAsync(new StubPermissionService("workflows/instances:view")); + + var workflows = Assert.Single(items, x => x.Text == "Workflows"); + Assert.Equal(["Instances"], workflows.SubMenuItems.Select(x => x.Text)); + } + + [Fact] + public async Task AParentsOwnRequirement_HidesItRegardlessOfItsChildren() + { + _workflows.RequiredPermissions.Add(new("system/features", "view")); + + var items = await GetMenuItemsAsync(new StubPermissionService("workflows/*:view")); + + Assert.DoesNotContain(items, x => x.Text == "Workflows"); + } + + [Fact] + public async Task HierarchicalWildcards_AreHonored() + { + var items = await GetMenuItemsAsync(new StubPermissionService("workflows/*:view")); + + var workflows = Assert.Single(items, x => x.Text == "Workflows"); + Assert.Equal(["Definitions", "Instances"], workflows.SubMenuItems.Select(x => x.Text)); + } + + [Fact] + public async Task TheWildcardGrant_SeesEverything() + { + var items = await GetMenuItemsAsync(new StubPermissionService("*")); + + Assert.Equal(["Dashboard", "Secrets", "Webhooks", "Workflows"], items.Select(x => x.Text).Order()); + Assert.Equal(2, Assert.Single(items, x => x.Text == "Workflows").SubMenuItems.Count); + } + + [Fact] + public async Task WhenPermissionsAreUnknown_EverythingIsShownAsBefore() + { + var items = await GetMenuItemsAsync(new StubPermissionService(UserPermissions.Unknown)); + + Assert.Equal(["Dashboard", "Secrets", "Webhooks", "Workflows"], items.Select(x => x.Text).Order()); + } + + [Fact] + public async Task WithoutAPermissionService_EverythingIsShownAsBefore() + { + var service = new DefaultMenuService([new StaticMenuProvider(_dashboard, _workflows)], []); + + var items = (await service.GetMenuItemsAsync()).ToList(); + + Assert.Equal(2, items.Count); + } + + [Fact] + public async Task Filtering_DoesNotMutateTheProvidersItems() + { + await GetMenuItemsAsync(new StubPermissionService("workflows/instances:view")); + + Assert.Equal(2, _workflows.SubMenuItems.Count); + } + + private async Task> GetMenuItemsAsync(IPermissionService permissionService) + { + var service = new DefaultMenuService([new StaticMenuProvider(_dashboard, _workflows, _secrets, _webhooks)], [], permissionService); + return (await service.GetMenuItemsAsync()).ToList(); + } + + private static MenuItem Item(string text, string href, params Permission[] requiredPermissions) => + new() { Text = text, Href = href, RequiredPermissions = requiredPermissions.ToList() }; + + private sealed class StaticMenuProvider(params MenuItem[] items) : IMenuProvider + { + public ValueTask> GetMenuItemsAsync(CancellationToken cancellationToken = default) => new(items); + } +} diff --git a/src/framework/Elsa.Studio.Core.Tests/Authorization/NavMenuTests.cs b/src/framework/Elsa.Studio.Core.Tests/Authorization/NavMenuTests.cs new file mode 100644 index 0000000000..dddb6c6633 --- /dev/null +++ b/src/framework/Elsa.Studio.Core.Tests/Authorization/NavMenuTests.cs @@ -0,0 +1,61 @@ +using Bunit; +using Elsa.Studio.Components; +using Elsa.Studio.Contracts; +using Elsa.Studio.Models; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.DependencyInjection; +using MudBlazor.Services; +using Xunit; + +namespace Elsa.Studio.Core.Tests.Authorization; + +/// +/// The menu is filtered by the user's permissions, so it must be rebuilt when the authentication state changes rather +/// than keep the items it loaded when the shell started. +/// +public sealed class NavMenuTests : BunitContext, IAsyncLifetime +{ + private readonly SwitchableMenuService _menu = new(); + private readonly NotifyingAuthenticationStateProvider _authentication = new(); + + public NavMenuTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddMudServices(); + Services.AddSingleton(_menu); + Services.AddSingleton(_authentication); + } + + [Fact] + public void TheMenuIsRebuiltWhenTheAuthenticationStateChanges() + { + _menu.Items = [Item("Workflows")]; + var cut = Render(); + cut.WaitForAssertion(() => Assert.Contains("Workflows", cut.Markup)); + + _menu.Items = [Item("Secrets")]; + _authentication.Notify(); + + cut.WaitForAssertion(() => + { + Assert.Contains("Secrets", cut.Markup); + Assert.DoesNotContain("Workflows", cut.Markup); + }); + } + + public Task InitializeAsync() => Task.CompletedTask; + + public new async Task DisposeAsync() => await base.DisposeAsync(); + + private static MenuItem Item(string text) => new() { Text = text, Href = text.ToLowerInvariant(), GroupName = "general" }; + + private sealed class SwitchableMenuService : IMenuService + { + public IReadOnlyList Items { get; set; } = []; + + public ValueTask> GetMenuItemsAsync(CancellationToken cancellationToken = default) => new(Items); + + public ValueTask> GetMenuItemGroupsAsync(CancellationToken cancellationToken = default) => + new([new MenuItemGroup("general", "General")]); + } +} diff --git a/src/framework/Elsa.Studio.Core.Tests/Authorization/NotifyingAuthenticationStateProvider.cs b/src/framework/Elsa.Studio.Core.Tests/Authorization/NotifyingAuthenticationStateProvider.cs new file mode 100644 index 0000000000..c22e79a2a5 --- /dev/null +++ b/src/framework/Elsa.Studio.Core.Tests/Authorization/NotifyingAuthenticationStateProvider.cs @@ -0,0 +1,14 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Components.Authorization; + +namespace Elsa.Studio.Core.Tests.Authorization; + +/// An authenticated user whose state change can be raised on demand. +internal sealed class NotifyingAuthenticationStateProvider : AuthenticationStateProvider +{ + private readonly AuthenticationState _state = new(new ClaimsPrincipal(new ClaimsIdentity("test"))); + + public override Task GetAuthenticationStateAsync() => Task.FromResult(_state); + + public void Notify() => NotifyAuthenticationStateChanged(Task.FromResult(_state)); +} diff --git a/src/framework/Elsa.Studio.Core.Tests/Authorization/PermissionMatcherTests.cs b/src/framework/Elsa.Studio.Core.Tests/Authorization/PermissionMatcherTests.cs new file mode 100644 index 0000000000..15d70783b0 --- /dev/null +++ b/src/framework/Elsa.Studio.Core.Tests/Authorization/PermissionMatcherTests.cs @@ -0,0 +1,46 @@ +using Elsa.Studio.Authorization; +using Xunit; + +namespace Elsa.Studio.Core.Tests.Authorization; + +public class PermissionMatcherTests +{ + [Theory] + [InlineData("secrets:view", "secrets:view", true)] + [InlineData("secrets:view", "secrets:write", false)] + [InlineData("secrets:view", "workflows/definitions:view", false)] + [InlineData("*", "workflows/instances:delete", true)] + [InlineData("*:*", "secrets:write", true)] + [InlineData("*:view", "dashboard:view", true)] + [InlineData("*:view", "dashboard:write", false)] + [InlineData("secrets:*", "secrets:test", true)] + [InlineData("secrets:*", "labels:view", false)] + [InlineData("workflows/*:view", "workflows:view", true)] + [InlineData("workflows/*:view", "workflows/definitions:view", true)] + [InlineData("workflows/*:view", "workflows/definitions/versions:view", true)] + [InlineData("workflows/*:view", "workflows/definitions:write", false)] + [InlineData("workflows/*:view", "workflows-legacy:view", false)] + [InlineData("workflows/*:view", "workflowsx/definitions:view", false)] + [InlineData("workflows/definitions:view", "workflows/definitions/versions:view", false)] + [InlineData("workflows/definitions/*:*", "workflows/definitions:publish", true)] + [InlineData("workflows/definitions/*:*", "workflows/instances:view", false)] + [InlineData("workflows*:view", "workflows/definitions:view", false)] + [InlineData("identity/users:view", "identity/users:view", true)] + [InlineData("Secrets:view", "secrets:view", false)] + public void Satisfies_FollowsTheBackendMatchingRules(string granted, string required, bool expected) + { + Assert.True(Permission.TryParse(granted, out var grantedPermission)); + Assert.True(Permission.TryParse(required, out var requiredPermission)); + + Assert.Equal(expected, PermissionMatcher.Satisfies(grantedPermission, requiredPermission)); + } + + [Fact] + public void Satisfies_WithSeveralGrants_PassesWhenAnyGrantMatches() + { + Permission[] grants = [new("secrets", "view"), new("labels", "*")]; + + Assert.True(PermissionMatcher.Satisfies(grants, new("labels", "delete"))); + Assert.False(PermissionMatcher.Satisfies(grants, new("secrets", "delete"))); + } +} diff --git a/src/framework/Elsa.Studio.Core.Tests/Authorization/PermissionPageGuardTests.cs b/src/framework/Elsa.Studio.Core.Tests/Authorization/PermissionPageGuardTests.cs new file mode 100644 index 0000000000..ebaba00dd9 --- /dev/null +++ b/src/framework/Elsa.Studio.Core.Tests/Authorization/PermissionPageGuardTests.cs @@ -0,0 +1,107 @@ +using Bunit; +using Elsa.Studio.Authorization; +using Elsa.Studio.Components; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.DependencyInjection; +using MudBlazor.Services; +using Xunit; + +namespace Elsa.Studio.Core.Tests.Authorization; + +public sealed class PermissionPageGuardTests : BunitContext, IAsyncLifetime +{ + private const string PageContent = "page-content"; + + public PermissionPageGuardTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddMudServices(); + } + + [Fact] + public void AnUngatedPage_RendersWithoutResolvingPermissions() + { + var permissions = new StubPermissionService("secrets:view"); + + var cut = RenderGuard(permissions); + + Assert.Contains(PageContent, cut.Markup); + Assert.Equal(0, permissions.Calls); + } + + [Fact] + public void AGatedPage_RendersWhenTheUserHoldsEveryRequiredPermission() + { + var cut = RenderGuard(new StubPermissionService("workflows/*:view")); + + Assert.Contains(PageContent, cut.Markup); + Assert.Empty(cut.FindAll("[data-testid='access-denied']")); + } + + [Fact] + public void AGatedPage_RendersAccessDeniedInsteadOfThePage_WhenAPermissionIsMissing() + { + var cut = RenderGuard(new StubPermissionService("secrets:view", "workflows/instances:view")); + + Assert.DoesNotContain(PageContent, cut.Markup); + var alert = cut.Find("[data-testid='access-denied']"); + Assert.Contains("workflows/definitions:view", alert.TextContent); + Assert.DoesNotContain("workflows/instances:view", alert.TextContent); + } + + [Fact] + public void AGatedPage_RendersAsBefore_WhenPermissionsAreUnknown() + { + var cut = RenderGuard(new StubPermissionService(UserPermissions.Unknown)); + + Assert.Contains(PageContent, cut.Markup); + } + + [Fact] + public void WithoutRouteData_TheContentRenders() + { + Services.AddSingleton(new StubPermissionService()); + + var cut = Render(parameters => parameters.AddChildContent(PageContent)); + + Assert.Contains(PageContent, cut.Markup); + } + + [Fact] + public void AMountedPage_FollowsPermissionChangesWhenTheAuthenticationStateChanges() + { + var permissions = new StubPermissionService("workflows/*:view"); + var authentication = new NotifyingAuthenticationStateProvider(); + Services.AddSingleton(authentication); + var cut = RenderGuard(permissions); + Assert.Contains(PageContent, cut.Markup); + + permissions.Permissions = StubPermissionService.Grants("secrets:view"); + authentication.Notify(); + cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll("[data-testid='access-denied']"))); + + permissions.Permissions = StubPermissionService.Grants("workflows/*:view"); + authentication.Notify(); + cut.WaitForAssertion(() => Assert.Contains(PageContent, cut.Markup)); + } + + public Task InitializeAsync() => Task.CompletedTask; + + public new async Task DisposeAsync() => await base.DisposeAsync(); + + private IRenderedComponent RenderGuard(IPermissionService permissionService) + { + Services.AddSingleton(permissionService); + + return Render(parameters => parameters + .AddCascadingValue(new RouteData(typeof(TPage), new Dictionary())) + .AddChildContent(PageContent)); + } + + private sealed class UngatedPage : ComponentBase; + + [RequirePermission("workflows/instances", PermissionVerbs.View)] + [RequirePermission("workflows/definitions", PermissionVerbs.View)] + private sealed class WorkflowInstancesPage : ComponentBase; +} diff --git a/src/framework/Elsa.Studio.Core.Tests/Authorization/PermissionTests.cs b/src/framework/Elsa.Studio.Core.Tests/Authorization/PermissionTests.cs new file mode 100644 index 0000000000..0a5b8a6ebc --- /dev/null +++ b/src/framework/Elsa.Studio.Core.Tests/Authorization/PermissionTests.cs @@ -0,0 +1,36 @@ +using Elsa.Studio.Authorization; +using Xunit; + +namespace Elsa.Studio.Core.Tests.Authorization; + +public class PermissionTests +{ + [Theory] + [InlineData("secrets:view", "secrets", "view")] + [InlineData(" workflows/*:view ", "workflows/*", "view")] + [InlineData("*", "*", "*")] + [InlineData("*:view", "*", "view")] + public void TryParse_AcceptsWellFormedPermissions(string value, string resource, string verb) + { + Assert.True(Permission.TryParse(value, out var permission)); + Assert.Equal(new Permission(resource, verb), permission); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("secrets")] + [InlineData(":view")] + [InlineData("secrets:")] + [InlineData("external-authentication:connections:read")] + [InlineData("workflows:definitions/view")] + [InlineData("secrets,labels:view")] + public void TryParse_RejectsMalformedPermissions(string? value) + { + Assert.False(Permission.TryParse(value, out _)); + } + + [Fact] + public void ToString_WritesResourceAndVerb() => Assert.Equal("workflows/instances:view", new Permission("workflows/instances", "view").ToString()); +} diff --git a/src/framework/Elsa.Studio.Core.Tests/Authorization/StubPermissionService.cs b/src/framework/Elsa.Studio.Core.Tests/Authorization/StubPermissionService.cs new file mode 100644 index 0000000000..59e40afcfd --- /dev/null +++ b/src/framework/Elsa.Studio.Core.Tests/Authorization/StubPermissionService.cs @@ -0,0 +1,25 @@ +using Elsa.Studio.Authorization; + +namespace Elsa.Studio.Core.Tests.Authorization; + +/// Returns fixed permissions and counts how often they were requested. +internal sealed class StubPermissionService(UserPermissions permissions) : IPermissionService +{ + public UserPermissions Permissions { get; set; } = permissions; + + public StubPermissionService(params string[] grants) : this(Grants(grants)) + { + } + + public int Calls { get; private set; } + + public ValueTask GetPermissionsAsync(CancellationToken cancellationToken = default) + { + Calls++; + return new(Permissions); + } + + public static UserPermissions Grants(params string[] grants) => UserPermissions.FromGrants(grants.Select(Parse)); + + private static Permission Parse(string value) => Permission.TryParse(value, out var permission) ? permission : throw new FormatException(value); +} diff --git a/src/framework/Elsa.Studio.Core.Tests/Elsa.Studio.Core.Tests.csproj b/src/framework/Elsa.Studio.Core.Tests/Elsa.Studio.Core.Tests.csproj index 33e2e2a5d6..88106369f8 100644 --- a/src/framework/Elsa.Studio.Core.Tests/Elsa.Studio.Core.Tests.csproj +++ b/src/framework/Elsa.Studio.Core.Tests/Elsa.Studio.Core.Tests.csproj @@ -8,6 +8,7 @@ + @@ -19,6 +20,7 @@ + diff --git a/src/framework/Elsa.Studio.Core.Tests/StudioShellCssContractTests.cs b/src/framework/Elsa.Studio.Core.Tests/StudioShellCssContractTests.cs index ff2ed7639f..35eb6d21c0 100644 --- a/src/framework/Elsa.Studio.Core.Tests/StudioShellCssContractTests.cs +++ b/src/framework/Elsa.Studio.Core.Tests/StudioShellCssContractTests.cs @@ -52,13 +52,11 @@ public void NavigationGroupLabelsUseComfortableLogicalInset() } [Theory] - [InlineData("Elsa.Studio.Login", "Components", "LoginState.razor")] - [InlineData("Elsa.Studio.Environments", "Components", "EnvironmentPicker.razor")] - [InlineData("Elsa.Studio.ExternalAuthentication.BlazorServer", "Components", "BrokerLoginState.razor")] - [InlineData("Elsa.Studio.ExternalAuthentication.BlazorWasm", "Components", "BrokerLoginState.razor")] + [InlineData("framework", "Elsa.Studio.Shared", "Components", "AppBar", "UserMenu.razor")] + [InlineData("modules", "Elsa.Studio.Environments", "Components", "EnvironmentPicker.razor")] public void AccountMenusInheritShellUtilityColor(params string[] componentPath) { - var path = new[] { "src", "modules" }.Concat(componentPath).ToArray(); + var path = new[] { "src" }.Concat(componentPath).ToArray(); var component = CssContractTestContext.ReadRepositoryFile(path); Assert.Contains("IconColor=\"Color.Inherit\"", component, StringComparison.Ordinal); diff --git a/src/framework/Elsa.Studio.Core/Authorization/ClaimsPermissionService.cs b/src/framework/Elsa.Studio.Core/Authorization/ClaimsPermissionService.cs new file mode 100644 index 0000000000..812526fc76 --- /dev/null +++ b/src/framework/Elsa.Studio.Core/Authorization/ClaimsPermissionService.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Components.Authorization; + +namespace Elsa.Studio.Authorization; + +/// +/// Reads the permissions claims Elsa issues into its access tokens (Elsa Identity, and the external +/// authentication broker, which issues Elsa tokens). +/// +/// +/// Fails open: when there is no authentication state, the user is not authenticated, or the principal carries no +/// permissions claims at all, the result is and Studio renders +/// everything, exactly as it did before permission gating. Providers that do not put Elsa permissions into the +/// token (for example a third-party OpenID Connect provider) therefore keep today's behavior, and route-level +/// authentication remains the job of AuthorizeRouteView. +/// +public sealed class ClaimsPermissionService(AuthenticationStateProvider? authenticationStateProvider = null) : IPermissionService +{ + /// The claim type Elsa uses for permission grants. + public const string ClaimType = "permissions"; + + /// + public async ValueTask GetPermissionsAsync(CancellationToken cancellationToken = default) + { + if (authenticationStateProvider is null) + return UserPermissions.Unknown; + + var user = (await authenticationStateProvider.GetAuthenticationStateAsync()).User; + + if (user.Identity?.IsAuthenticated != true) + return UserPermissions.Unknown; + + var claims = user.FindAll(ClaimType).ToList(); + + if (claims.Count == 0) + return UserPermissions.Unknown; + + // Malformed values are skipped rather than failing the whole set, matching the backend evaluator. + var grants = claims + .Select(claim => Permission.TryParse(claim.Value, out var permission) ? permission : (Permission?)null) + .OfType(); + + return UserPermissions.FromGrants(grants); + } +} diff --git a/src/framework/Elsa.Studio.Core/Authorization/IPermissionService.cs b/src/framework/Elsa.Studio.Core/Authorization/IPermissionService.cs new file mode 100644 index 0000000000..b93ab5bf9a --- /dev/null +++ b/src/framework/Elsa.Studio.Core/Authorization/IPermissionService.cs @@ -0,0 +1,10 @@ +namespace Elsa.Studio.Authorization; + +/// +/// Resolves the current user's permissions so Studio can hide navigation, pages and actions the user cannot use. +/// +public interface IPermissionService +{ + /// Returns the current user's permissions, or when none are available. + ValueTask GetPermissionsAsync(CancellationToken cancellationToken = default); +} diff --git a/src/framework/Elsa.Studio.Core/Authorization/Permission.cs b/src/framework/Elsa.Studio.Core/Authorization/Permission.cs new file mode 100644 index 0000000000..59b26af55c --- /dev/null +++ b/src/framework/Elsa.Studio.Core/Authorization/Permission.cs @@ -0,0 +1,64 @@ +namespace Elsa.Studio.Authorization; + +/// +/// A permission: a hierarchical resource path paired with a verb, written {resource}:{verb}. +/// +/// +/// Mirrors the backend's permission grammar so Studio evaluates grants exactly as the server does. A trailing +/// /* on the resource matches the named node and every descendant, a bare * resource matches +/// everything, and * as a verb matches any verb. +/// +public readonly record struct Permission(string Resource, string Verb) +{ + /// Separates the resource from the verb. + public const char Separator = ':'; + + /// Separates resource path segments. + public const char PathSeparator = '/'; + + /// Matches any resource, or any verb, depending on the axis it appears on. + public const string Wildcard = "*"; + + /// The whole vocabulary. + public static Permission All { get; } = new(Wildcard, Wildcard); + + /// + /// Parses , returning false when it is not a well-formed permission. A bare + /// * normalizes to . + /// + public static bool TryParse(string? value, out Permission permission) + { + permission = default; + + if (string.IsNullOrWhiteSpace(value)) + return false; + + var trimmed = value.Trim(); + + if (trimmed == Wildcard) + { + permission = All; + return true; + } + + if (trimmed.Contains(',')) + return false; + + var separator = trimmed.IndexOf(Separator); + + if (separator <= 0 || separator == trimmed.Length - 1) + return false; + + var resource = trimmed[..separator]; + var verb = trimmed[(separator + 1)..]; + + if (verb.IndexOf(Separator) >= 0 || verb.IndexOf(PathSeparator) >= 0) + return false; + + permission = new(resource, verb); + return true; + } + + /// + public override string ToString() => $"{Resource}{Separator}{Verb}"; +} diff --git a/src/framework/Elsa.Studio.Core/Authorization/PermissionMatcher.cs b/src/framework/Elsa.Studio.Core/Authorization/PermissionMatcher.cs new file mode 100644 index 0000000000..cc448f5789 --- /dev/null +++ b/src/framework/Elsa.Studio.Core/Authorization/PermissionMatcher.cs @@ -0,0 +1,41 @@ +namespace Elsa.Studio.Authorization; + +/// +/// Decides whether a held permission satisfies a required one, using the same rules as the backend evaluator: +/// exact match, or a wildcard on either axis. +/// +public static class PermissionMatcher +{ + /// Whether satisfies . + public static bool Satisfies(Permission granted, Permission required) => + ResourceMatches(granted.Resource, required.Resource) && VerbMatches(granted.Verb, required.Verb); + + /// Whether any of satisfies . + public static bool Satisfies(IEnumerable granted, Permission required) => + granted.Any(x => Satisfies(x, required)); + + /// + /// Whether a granted resource pattern covers a required resource. A trailing /* matches the named node + /// and every descendant at any depth; a bare * matches everything. + /// + public static bool ResourceMatches(string granted, string required) + { + if (granted == Permission.Wildcard || string.Equals(granted, required, StringComparison.Ordinal)) + return true; + + if (!granted.EndsWith($"{Permission.PathSeparator}{Permission.Wildcard}", StringComparison.Ordinal)) + return false; + + // 'workflows/*' covers 'workflows' itself as well as everything beneath it. + var prefix = granted[..^2]; + + return string.Equals(prefix, required, StringComparison.Ordinal) + || required.Length > prefix.Length + && required.StartsWith(prefix, StringComparison.Ordinal) + && required[prefix.Length] == Permission.PathSeparator; + } + + /// Whether a granted verb covers a required verb. + public static bool VerbMatches(string granted, string required) => + granted == Permission.Wildcard || string.Equals(granted, required, StringComparison.Ordinal); +} diff --git a/src/framework/Elsa.Studio.Core/Authorization/PermissionVerbs.cs b/src/framework/Elsa.Studio.Core/Authorization/PermissionVerbs.cs new file mode 100644 index 0000000000..bf17c91315 --- /dev/null +++ b/src/framework/Elsa.Studio.Core/Authorization/PermissionVerbs.cs @@ -0,0 +1,23 @@ +namespace Elsa.Studio.Authorization; + +/// The backend's recommended core verbs. +public static class PermissionVerbs +{ + /// Read, list, query, inspect, export. + public const string View = "view"; + + /// Bring a new record into existence. + public const string Create = "create"; + + /// Modify an existing record. + public const string Update = "update"; + + /// Create or modify, where the API does not separate the two. + public const string Write = "write"; + + /// Remove a record. + public const string Delete = "delete"; + + /// Run, dispatch, or invoke against a live system. + public const string Execute = "execute"; +} diff --git a/src/framework/Elsa.Studio.Core/Authorization/RequirePermissionAttribute.cs b/src/framework/Elsa.Studio.Core/Authorization/RequirePermissionAttribute.cs new file mode 100644 index 0000000000..1662f66ca4 --- /dev/null +++ b/src/framework/Elsa.Studio.Core/Authorization/RequirePermissionAttribute.cs @@ -0,0 +1,19 @@ +namespace Elsa.Studio.Authorization; + +/// +/// Declares a permission a routable page requires. Studio's shell renders an access-denied state instead of the +/// page, so the page never issues API calls the backend would reject. Multiple attributes are all required. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public sealed class RequirePermissionAttribute(string resource, string verb) : Attribute +{ + /// The required permission. + public Permission Permission { get; } = new(resource, verb); + + /// Returns every permission declared on , including inherited declarations. + public static IReadOnlyCollection GetRequiredPermissions(Type type) => + type.GetCustomAttributes(typeof(RequirePermissionAttribute), inherit: true) + .Cast() + .Select(x => x.Permission) + .ToArray(); +} diff --git a/src/framework/Elsa.Studio.Core/Authorization/UserPermissions.cs b/src/framework/Elsa.Studio.Core/Authorization/UserPermissions.cs new file mode 100644 index 0000000000..1a3d53b8ee --- /dev/null +++ b/src/framework/Elsa.Studio.Core/Authorization/UserPermissions.cs @@ -0,0 +1,38 @@ +namespace Elsa.Studio.Authorization; + +/// +/// The permissions the current user holds, used only to tailor what Studio renders. The backend re-evaluates +/// every request, so this is never an authorization decision. +/// +public sealed class UserPermissions +{ + private UserPermissions(bool isKnown, IReadOnlyCollection grants) + { + IsKnown = isKnown; + Grants = grants; + } + + /// + /// No permission information is available (for example, an authentication provider whose tokens carry no + /// permissions claims). Every check passes, so Studio behaves as it did before permission gating. + /// + public static UserPermissions Unknown { get; } = new(false, []); + + /// Creates a known permission set from the user's grants. + public static UserPermissions FromGrants(IEnumerable grants) => new(true, grants.Distinct().ToArray()); + + /// Whether permission information is available. When it is not, every check passes. + public bool IsKnown { get; } + + /// The grants held by the user. Empty when is false. + public IReadOnlyCollection Grants { get; } + + /// Whether the user holds a grant satisfying . + public bool Has(Permission required) => !IsKnown || PermissionMatcher.Satisfies(Grants, required); + + /// Whether the user holds grants satisfying every one of . + public bool HasAll(IEnumerable required) => required.All(Has); + + /// The permissions in the user does not hold. + public IReadOnlyCollection GetMissing(IEnumerable required) => required.Where(x => !Has(x)).ToArray(); +} diff --git a/src/framework/Elsa.Studio.Core/Extensions/ServiceCollectionExtensions.cs b/src/framework/Elsa.Studio.Core/Extensions/ServiceCollectionExtensions.cs index e30008c1ec..610b050fc6 100644 --- a/src/framework/Elsa.Studio.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/framework/Elsa.Studio.Core/Extensions/ServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using Elsa.Api.Client.Extensions; +using Elsa.Studio.Authorization; using Elsa.Studio.Contracts; using Elsa.Studio.Localization; using Elsa.Studio.Models; @@ -61,6 +62,9 @@ public static IServiceCollection AddCoreInternal( .AddUserMessageService() ; + // Permission-aware rendering. Hosts or modules can replace the source of permission information. + services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); diff --git a/src/framework/Elsa.Studio.Core/Models/MenuItem.cs b/src/framework/Elsa.Studio.Core/Models/MenuItem.cs index 6b8bff2481..a178499618 100644 --- a/src/framework/Elsa.Studio.Core/Models/MenuItem.cs +++ b/src/framework/Elsa.Studio.Core/Models/MenuItem.cs @@ -1,3 +1,4 @@ +using Elsa.Studio.Authorization; using Microsoft.AspNetCore.Components.Routing; namespace Elsa.Studio.Models; @@ -41,4 +42,17 @@ public class MenuItem /// The name of the group this menu item belongs to. /// public string GroupName { get; set; } = "General"; + + /// + /// The permissions the current user must hold for this item to be shown. All are required. An item whose + /// sub menu items are all hidden is hidden as well. + /// + public ICollection RequiredPermissions { get; set; } = new List(); + + internal MenuItem WithSubMenuItems(ICollection subMenuItems) + { + var clone = (MenuItem)MemberwiseClone(); + clone.SubMenuItems = subMenuItems; + return clone; + } } \ No newline at end of file diff --git a/src/framework/Elsa.Studio.Core/Services/DefaultMenuService.cs b/src/framework/Elsa.Studio.Core/Services/DefaultMenuService.cs index 873392791c..6a9d4075d0 100644 --- a/src/framework/Elsa.Studio.Core/Services/DefaultMenuService.cs +++ b/src/framework/Elsa.Studio.Core/Services/DefaultMenuService.cs @@ -1,3 +1,4 @@ +using Elsa.Studio.Authorization; using Elsa.Studio.Contracts; using Elsa.Studio.Models; @@ -8,35 +9,39 @@ public class DefaultMenuService : IMenuService { private readonly IEnumerable _menuProviders; private readonly IEnumerable _menuGroupProviders; + private readonly IPermissionService? _permissionService; /// /// Initializes a new instance of the class. /// - public DefaultMenuService(IEnumerable menuProviders, IEnumerable menuGroupProviders) + public DefaultMenuService(IEnumerable menuProviders, IEnumerable menuGroupProviders, IPermissionService? permissionService = null) { _menuProviders = menuProviders; _menuGroupProviders = menuGroupProviders; + _permissionService = permissionService; } /// public async ValueTask> GetMenuItemsAsync(CancellationToken cancellationToken = default) { var menu = new List(); - + foreach (var menuProvider in _menuProviders) { var menuItems = await menuProvider.GetMenuItemsAsync(cancellationToken); menu.AddRange(menuItems); } - return menu.OrderBy(x => x.Order).ToList(); + var permissions = _permissionService != null ? await _permissionService.GetPermissionsAsync(cancellationToken) : UserPermissions.Unknown; + + return FilterByPermissions(menu, permissions).OrderBy(x => x.Order).ToList(); } /// public async ValueTask> GetMenuItemGroupsAsync(CancellationToken cancellationToken = default) { var groups = new List(); - + foreach (var menuGroupProvider in _menuGroupProviders) { var menuGroups = await menuGroupProvider.GetMenuGroupsAsync(cancellationToken); @@ -45,4 +50,28 @@ public async ValueTask> GetMenuItemGroupsAsync(Cancel return groups.DistinctBy(x => x.Name).OrderBy(x => x.Order).ToList(); } -} \ No newline at end of file + + /// + /// Removes the items the user lacks permissions for, and parents left without any visible children. Items are + /// copied rather than mutated, since providers may hand out shared instances. + /// + private static IEnumerable FilterByPermissions(IEnumerable items, UserPermissions permissions) + { + foreach (var item in items) + { + if (!permissions.HasAll(item.RequiredPermissions)) + continue; + + if (item.SubMenuItems.Count == 0) + { + yield return item; + continue; + } + + var subMenuItems = FilterByPermissions(item.SubMenuItems, permissions).ToList(); + + if (subMenuItems.Count > 0) + yield return item.WithSubMenuItems(subMenuItems); + } + } +} diff --git a/src/framework/Elsa.Studio.Shared/Components/AccessDenied.razor b/src/framework/Elsa.Studio.Shared/Components/AccessDenied.razor new file mode 100644 index 0000000000..f5e860aea9 --- /dev/null +++ b/src/framework/Elsa.Studio.Shared/Components/AccessDenied.razor @@ -0,0 +1,16 @@ +@using Elsa.Studio.Authorization + + + + You don't have access to this page + + Your current sign-in does not include @string.Join(", ", MissingPermissions). + Ask an administrator to grant access, then sign in again. + + + + +@code { + /// The permissions the user is missing. + [Parameter, EditorRequired] public IReadOnlyCollection MissingPermissions { get; set; } = []; +} diff --git a/src/framework/Elsa.Studio.Shared/Components/AppBar/UserMenu.razor b/src/framework/Elsa.Studio.Shared/Components/AppBar/UserMenu.razor new file mode 100644 index 0000000000..2280dd874e --- /dev/null +++ b/src/framework/Elsa.Studio.Shared/Components/AppBar/UserMenu.razor @@ -0,0 +1,42 @@ +@using Microsoft.AspNetCore.Components.Authorization +@implements IDisposable +@inject AuthenticationStateProvider AuthenticationStateProvider + +@if (_currentState?.User.Identity?.IsAuthenticated is true) +{ + + @ChildContent + +} + +@code { + private AuthenticationState? _currentState; + + /// + /// The menu entries (e.g. sign-out actions) shown to the signed-in user. + /// + [Parameter] public RenderFragment? ChildContent { get; set; } + + /// + protected override async Task OnInitializedAsync() + { + _currentState = await AuthenticationStateProvider.GetAuthenticationStateAsync(); + AuthenticationStateProvider.AuthenticationStateChanged += OnAuthenticationStateChanged; + } + + /// + public void Dispose() => AuthenticationStateProvider.AuthenticationStateChanged -= OnAuthenticationStateChanged; + + private async void OnAuthenticationStateChanged(Task state) + { + _currentState = await state; + await InvokeAsync(StateHasChanged); + } +} diff --git a/src/framework/Elsa.Studio.Shared/Components/BusyButtonContent.razor b/src/framework/Elsa.Studio.Shared/Components/BusyButtonContent.razor new file mode 100644 index 0000000000..49f809010e --- /dev/null +++ b/src/framework/Elsa.Studio.Shared/Components/BusyButtonContent.razor @@ -0,0 +1,21 @@ +@* Button content that swaps to a progress indicator and a busy label while an operation is in flight. *@ +@if (Busy) +{ +