test(labels): share InMemory/EF store conformance scenarios (#8122)

* test(labels): share InMemory/EF store conformance scenarios

Add one abstract Labels store suite and run it against InMemory and
EF Core (SQLite) so tenant isolation, NormalizedName uniqueness,
cascade delete, ReplaceAsync, and association lookups stay aligned.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test(labels): drop provider-specific uniqueness and paging asserts

SaveMany name swaps trip EF BulkUpsert unique indexes, and Memory
ToPage counts after Skip/Take. Keep the shared matrix on contracts
both InMemory and EF/SQLite honor.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test(labels): map conformance matrix to the #8091 lock list

Keep one scenario per issue invariant so the InMemory/EF harness stays
small and readable: cascade delete, ReplaceAsync, association
finds/deletes, ListAsync order/paging, tenant stamp/isolation, and
per-tenant NormalizedName uniqueness.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test(labels): cover cross-tenant lookup conformance

* test(labels): use Path.Join for sqlite fixture

* test(labels): lock ID-only ReplaceAsync and SaveMany tenant stamp

Vary every field except Id on the removed association so ReplaceAsync
cannot match the stored row by payload. Also stamp null-tenant batches
through SaveManyAsync on both label and association stores.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test(labels): stamp null TenantId under a named ambient tenant

Cover SaveAsync and SaveManyAsync for labels and associations when
the ambient tenant is tenant-a, so a default-only stamp cannot pass.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test(labels): reject tenant-A association leakage under tenant-B

Tenant-B FindByVersion and FindByLabelIds now require assoc-b and
assoc-star, and fail if assoc-a is visible.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Sipke Schoorstra 2026-09-13 18:58:36 +02:00 committed by GitHub
parent f3e77fe3e7
commit fe9217bdfa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 548 additions and 0 deletions

View file

@ -525,6 +525,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "integration", "integration"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Workflows.Persistence.ConformanceTests", "test\integration\Elsa.Workflows.Persistence.ConformanceTests\Elsa.Workflows.Persistence.ConformanceTests.csproj", "{E099B352-C3E6-499D-B141-67B209125B67}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Labels.Persistence.ConformanceTests", "test\integration\Elsa.Labels.Persistence.ConformanceTests\Elsa.Labels.Persistence.ConformanceTests.csproj", "{7C4E1A92-6B8F-4D3A-9E11-2F5A8C0B4D17}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -2513,6 +2515,18 @@ Global
{E099B352-C3E6-499D-B141-67B209125B67}.Release|x64.Build.0 = Release|Any CPU
{E099B352-C3E6-499D-B141-67B209125B67}.Release|x86.ActiveCfg = Release|Any CPU
{E099B352-C3E6-499D-B141-67B209125B67}.Release|x86.Build.0 = Release|Any CPU
{7C4E1A92-6B8F-4D3A-9E11-2F5A8C0B4D17}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7C4E1A92-6B8F-4D3A-9E11-2F5A8C0B4D17}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7C4E1A92-6B8F-4D3A-9E11-2F5A8C0B4D17}.Debug|x64.ActiveCfg = Debug|Any CPU
{7C4E1A92-6B8F-4D3A-9E11-2F5A8C0B4D17}.Debug|x64.Build.0 = Debug|Any CPU
{7C4E1A92-6B8F-4D3A-9E11-2F5A8C0B4D17}.Debug|x86.ActiveCfg = Debug|Any CPU
{7C4E1A92-6B8F-4D3A-9E11-2F5A8C0B4D17}.Debug|x86.Build.0 = Debug|Any CPU
{7C4E1A92-6B8F-4D3A-9E11-2F5A8C0B4D17}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7C4E1A92-6B8F-4D3A-9E11-2F5A8C0B4D17}.Release|Any CPU.Build.0 = Release|Any CPU
{7C4E1A92-6B8F-4D3A-9E11-2F5A8C0B4D17}.Release|x64.ActiveCfg = Release|Any CPU
{7C4E1A92-6B8F-4D3A-9E11-2F5A8C0B4D17}.Release|x64.Build.0 = Release|Any CPU
{7C4E1A92-6B8F-4D3A-9E11-2F5A8C0B4D17}.Release|x86.ActiveCfg = Release|Any CPU
{7C4E1A92-6B8F-4D3A-9E11-2F5A8C0B4D17}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -2721,6 +2735,7 @@ Global
{ED8D7C78-154D-4124-8C0A-E63785A862E5} = {18453B51-25EB-4317-A4B3-B10518252E92}
{EAF427BE-7A07-42D7-A223-70FB1FC91BB2} = {18453B51-25EB-4317-A4B3-B10518252E92}
{E099B352-C3E6-499D-B141-67B209125B67} = {823D4020-332D-2C13-F261-6F510F11A57E}
{7C4E1A92-6B8F-4D3A-9E11-2F5A8C0B4D17} = {823D4020-332D-2C13-F261-6F510F11A57E}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E}

View file

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Include>[Elsa.Labels]*,[Elsa.Persistence.EFCore]*</Include>
<Threshold>0</Threshold>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite"/>
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\common\Elsa.Testing.Shared\Elsa.Testing.Shared.csproj"/>
<ProjectReference Include="..\..\..\src\modules\Elsa.Labels\Elsa.Labels.csproj"/>
<ProjectReference Include="..\..\..\src\modules\Elsa.Persistence.EFCore.Sqlite\Elsa.Persistence.EFCore.Sqlite.csproj"/>
</ItemGroup>
</Project>

View file

@ -0,0 +1,382 @@
using Elsa.Common.Models;
using Elsa.Common.Multitenancy;
using Elsa.Labels.Entities;
namespace Elsa.Labels.Persistence.ConformanceTests;
/// <summary>
/// Shared InMemory / EF Core store-contract assertions for Labels ports.
/// </summary>
public abstract class LabelStoreConformanceTests
{
protected abstract Task<LabelStoreScenario> CreateScenarioAsync();
[Fact]
public async Task DeletingALabelCascadesItsAssociations()
{
await using var scenario = await CreateScenarioAsync();
await scenario.Labels.SaveAsync(Label("label-a", "A", "tenant-a"));
await scenario.Labels.SaveAsync(Label("label-keep", "Keep", "tenant-a"));
await scenario.Associations.SaveAsync(Association("assoc-a1", "label-a", "order", "order:1", "tenant-a"));
await scenario.Associations.SaveAsync(Association("assoc-a2", "label-a", "invoice", "invoice:1", "tenant-a"));
await scenario.Associations.SaveAsync(Association("assoc-keep", "label-keep", "order", "order:1", "tenant-a"));
using (scenario.UseTenant("tenant-b"))
{
await scenario.Labels.SaveAsync(Label("label-b", "A", "tenant-b"));
await scenario.Associations.SaveAsync(Association("assoc-b", "label-b", "order", "order:1", "tenant-b"));
}
Assert.True(await scenario.Labels.DeleteAsync("label-a"));
Assert.Empty(await scenario.AssociationQuery.FindByLabelIdsAsync(["label-a"]));
Assert.Equal("assoc-keep", Assert.Single(await scenario.AssociationQuery.FindByLabelIdsAsync(["label-keep"])).Id);
using (scenario.UseTenant("tenant-b"))
{
Assert.NotNull(await scenario.Labels.FindByIdAsync("label-b"));
Assert.Equal("assoc-b", Assert.Single(await scenario.AssociationQuery.FindByLabelIdsAsync(["label-b"])).Id);
}
await scenario.Labels.SaveAsync(Label("label-x", "X", "tenant-a"));
await scenario.Labels.SaveAsync(Label("label-y", "Y", "tenant-a"));
await scenario.Associations.SaveAsync(Association("assoc-x", "label-x", "order", "order:2", "tenant-a"));
await scenario.Associations.SaveAsync(Association("assoc-y", "label-y", "order", "order:2", "tenant-a"));
Assert.Equal(2, await scenario.Labels.DeleteManyAsync(["label-x", "label-y"]));
Assert.Empty(await scenario.AssociationQuery.FindByLabelIdsAsync(["label-x", "label-y"]));
Assert.Equal("assoc-keep", Assert.Single(await scenario.AssociationQuery.FindByLabelIdsAsync(["label-keep"])).Id);
}
[Fact]
public async Task ReplaceAsyncRemovesAndAddsByAssociationId()
{
await using var scenario = await CreateScenarioAsync();
await scenario.Associations.SaveAsync(Association("assoc-red", "red", "order", "order:1", "tenant-a"));
await scenario.Associations.SaveAsync(Association("assoc-blue", "blue", "order", "order:1", "tenant-a"));
await scenario.Associations.ReplaceAsync(
[Association("assoc-red", "wrong-label", "other", "other:9", "tenant-b")],
[Association("assoc-green", "green", "order", "order:1", "tenant-a")]);
var remaining = (await scenario.Associations.FindByWorkflowDefinitionVersionIdAsync("order:1"))
.Select(x => x.Id)
.OrderBy(x => x)
.ToList();
Assert.Equal(["assoc-blue", "assoc-green"], remaining);
}
[Fact]
public async Task AssociationFindsAndDeletesHonorWorkflowDefinitionKeys()
{
await using var scenario = await CreateScenarioAsync();
await scenario.Associations.SaveAsync(Association("assoc-v1-red", "red", "order", "order:1", "tenant-a"));
await scenario.Associations.SaveAsync(Association("assoc-v1-blue", "blue", "order", "order:1", "tenant-a"));
await scenario.Associations.SaveAsync(Association("assoc-v2-red", "red", "order", "order:2", "tenant-a"));
await scenario.Associations.SaveAsync(Association("assoc-invoice", "red", "invoice", "invoice:1", "tenant-a"));
var version1 = (await scenario.Associations.FindByWorkflowDefinitionVersionIdAsync("order:1")).ToList();
Assert.Equal(2, version1.Count);
Assert.Contains(version1, x => x.Id == "assoc-v1-red");
Assert.Contains(version1, x => x.Id == "assoc-v1-blue");
Assert.Empty(await scenario.Associations.FindByWorkflowDefinitionVersionIdAsync("missing"));
Assert.Empty(await scenario.AssociationQuery.FindByLabelIdsAsync([]));
Assert.Empty(await scenario.AssociationQuery.FindByLabelIdsAsync(["unknown"]));
var byRed = (await scenario.AssociationQuery.FindByLabelIdsAsync(["red"])).ToList();
Assert.Equal(3, byRed.Count);
Assert.Contains(byRed, x => x.Id == "assoc-v1-red");
Assert.Contains(byRed, x => x.Id == "assoc-v2-red");
Assert.Contains(byRed, x => x.Id == "assoc-invoice");
var byRedAndBlue = (await scenario.AssociationQuery.FindByLabelIdsAsync(["red", "blue"])).ToList();
Assert.Equal(4, byRedAndBlue.Count);
Assert.Contains(byRedAndBlue, x => x.Id == "assoc-v1-blue");
Assert.Equal(1, await scenario.Associations.DeleteByWorkflowDefinitionVersionIdAsync("order:2"));
Assert.Empty(await scenario.Associations.FindByWorkflowDefinitionVersionIdAsync("order:2"));
Assert.NotEmpty(await scenario.Associations.FindByWorkflowDefinitionVersionIdAsync("order:1"));
Assert.Equal(1, await scenario.Associations.DeleteByWorkflowDefinitionIdAsync("invoice"));
Assert.Empty(await scenario.Associations.FindByWorkflowDefinitionVersionIdAsync("invoice:1"));
await scenario.Associations.SaveManyAsync(
[
Association("assoc-pay-1", "red", "payroll", "payroll:1", "tenant-a"),
Association("assoc-pay-2", "red", "payroll", "payroll:2", "tenant-a"),
Association("assoc-hr-1", "blue", "hr", "hr:1", "tenant-a")
]);
Assert.Equal(2, await scenario.Associations.DeleteByWorkflowDefinitionVersionIdsAsync(["payroll:1", "payroll:2"]));
Assert.Equal(1, await scenario.Associations.DeleteByWorkflowDefinitionIdsAsync(["hr"]));
Assert.Empty(await scenario.Associations.FindByWorkflowDefinitionVersionIdAsync("payroll:1"));
Assert.Empty(await scenario.Associations.FindByWorkflowDefinitionVersionIdAsync("hr:1"));
Assert.NotEmpty(await scenario.Associations.FindByWorkflowDefinitionVersionIdAsync("order:1"));
}
[Fact]
public async Task ListAsyncOrdersByNameAndPages()
{
await using var scenario = await CreateScenarioAsync();
await scenario.Labels.SaveAsync(Label("label-z", "Zebra", "tenant-a"));
await scenario.Labels.SaveAsync(Label("label-a", "Apple", "tenant-a"));
await scenario.Labels.SaveAsync(Label("label-m", "Mango", "tenant-a"));
var all = (await scenario.Labels.ListAsync()).Items.Select(x => x.Name).ToList();
Assert.Equal(["Apple", "Mango", "Zebra"], all);
// Page.TotalCount is not a shared contract: Memory ToPage counts after Skip/Take,
// EF PaginateAsync counts the unpaged query.
var firstPage = await scenario.Labels.ListAsync(PageArgs.FromRange(0, 2));
Assert.Equal(["Apple", "Mango"], firstPage.Items.Select(x => x.Name).ToList());
var secondPage = await scenario.Labels.ListAsync(PageArgs.FromRange(2, 2));
Assert.Equal(["Zebra"], secondPage.Items.Select(x => x.Name).ToList());
}
[Fact]
public async Task TenantStampAndIsolationHonorAmbientTenant()
{
await using var scenario = await CreateScenarioAsync();
await SeedMixedLabelsAsync(scenario);
await SeedMixedAssociationsAsync(scenario);
var listed = (await scenario.Labels.ListAsync()).Items.ToList();
Assert.Equal(2, listed.Count);
Assert.Contains(listed, x => x.Id == "label-a");
Assert.Contains(listed, x => x.Id == "label-star");
Assert.DoesNotContain(listed, x => x.Id == "label-b");
Assert.Null(await scenario.Labels.FindByIdAsync("label-b"));
Assert.NotNull(await scenario.Labels.FindByIdAsync("label-a"));
Assert.NotNull(await scenario.Labels.FindByIdAsync("label-star"));
var foundMany = (await scenario.Labels.FindManyByIdAsync(["label-a", "label-b", "label-star"])).ToList();
Assert.Equal(2, foundMany.Count);
Assert.DoesNotContain(foundMany, x => x.Id == "label-b");
var byVersion = (await scenario.Associations.FindByWorkflowDefinitionVersionIdAsync("order:1")).ToList();
Assert.Equal(2, byVersion.Count);
Assert.Contains(byVersion, x => x.Id == "assoc-a");
Assert.Contains(byVersion, x => x.Id == "assoc-star");
Assert.DoesNotContain(byVersion, x => x.Id == "assoc-b");
var byLabel = (await scenario.AssociationQuery.FindByLabelIdsAsync(["red"])).ToList();
Assert.Equal(2, byLabel.Count);
Assert.DoesNotContain(byLabel, x => x.Id == "assoc-b");
using (scenario.UseTenant("tenant-b"))
{
var tenantBLabels = (await scenario.Labels.ListAsync()).Items.ToList();
Assert.Contains(tenantBLabels, x => x.Id == "label-star");
Assert.DoesNotContain(tenantBLabels, x => x.Id == "label-a");
Assert.Equal("label-star", (await scenario.Labels.FindByIdAsync("label-star"))!.Id);
var tenantBByVersion = (await scenario.Associations.FindByWorkflowDefinitionVersionIdAsync("order:1")).ToList();
Assert.Equal(2, tenantBByVersion.Count);
Assert.Contains(tenantBByVersion, x => x.Id == "assoc-b");
Assert.Contains(tenantBByVersion, x => x.Id == "assoc-star");
Assert.DoesNotContain(tenantBByVersion, x => x.Id == "assoc-a");
var tenantBByLabel = (await scenario.AssociationQuery.FindByLabelIdsAsync(["red"])).ToList();
Assert.Equal(2, tenantBByLabel.Count);
Assert.Contains(tenantBByLabel, x => x.Id == "assoc-b");
Assert.Contains(tenantBByLabel, x => x.Id == "assoc-star");
Assert.DoesNotContain(tenantBByLabel, x => x.Id == "assoc-a");
}
Assert.False(await scenario.Labels.DeleteAsync("label-b"));
Assert.False(await scenario.Associations.DeleteAsync("assoc-b"));
using (scenario.UseTenant("tenant-b"))
{
Assert.NotNull(await scenario.Labels.FindByIdAsync("label-b"));
Assert.Contains(await scenario.AssociationQuery.FindByLabelIdsAsync(["red"]), x => x.Id == "assoc-b");
}
await scenario.Associations.ReplaceAsync(
[Association("assoc-a", "red", "order", "order:1", "tenant-a"), Association("assoc-b", "red", "order", "order:1", "tenant-b")],
[Association("assoc-a2", "red", "order", "order:1", "tenant-a")]);
var remainingA = (await scenario.Associations.FindByWorkflowDefinitionVersionIdAsync("order:1")).ToList();
Assert.Contains(remainingA, x => x.Id == "assoc-a2");
Assert.Contains(remainingA, x => x.Id == "assoc-star");
Assert.DoesNotContain(remainingA, x => x.Id == "assoc-a");
using (scenario.UseTenant("tenant-b"))
Assert.Contains(await scenario.AssociationQuery.FindByLabelIdsAsync(["red"]), x => x.Id == "assoc-b");
var deletedLabels = await scenario.Labels.DeleteManyAsync(["label-a", "label-b", "label-star"]);
Assert.Equal(2, deletedLabels);
using (scenario.UseTenant("tenant-b"))
Assert.Equal("label-b", (await scenario.Labels.FindByIdAsync("label-b"))!.Id);
using (scenario.UseTenant("tenant-b"))
await scenario.Associations.SaveAsync(Association("assoc-b-order", "blue", "order", "order:1", "tenant-b"));
Assert.Equal(2, await scenario.Associations.DeleteByWorkflowDefinitionIdAsync("order"));
Assert.Empty(await scenario.Associations.FindByWorkflowDefinitionVersionIdAsync("order:1"));
using (scenario.UseTenant("tenant-b"))
{
var stillB = (await scenario.Associations.FindByWorkflowDefinitionVersionIdAsync("order:1")).ToList();
Assert.Contains(stillB, x => x.Id == "assoc-b");
Assert.Contains(stillB, x => x.Id == "assoc-b-order");
}
using (scenario.UseTenant(Tenant.DefaultTenantId))
{
var stampedLabel = Label("label-null", "Null", tenantId: null);
await scenario.Labels.SaveAsync(stampedLabel);
Assert.Equal(Tenant.DefaultTenantId, stampedLabel.TenantId);
var namedLabel = Label("label-named", "Named", "tenant-a");
await scenario.Labels.SaveAsync(namedLabel);
Assert.Equal("tenant-a", namedLabel.TenantId);
var defaultLabels = (await scenario.Labels.ListAsync()).Items.ToList();
Assert.Contains(defaultLabels, x => x.Id == "label-null");
Assert.DoesNotContain(defaultLabels, x => x.Id == "label-named");
var stampedAssociation = Association("assoc-null", "red", "order", "order:1", tenantId: null);
await scenario.Associations.SaveAsync(stampedAssociation);
Assert.Equal(Tenant.DefaultTenantId, stampedAssociation.TenantId);
Assert.Equal("assoc-null", Assert.Single(await scenario.AssociationQuery.FindByLabelIdsAsync(["red"])).Id);
var batchLabels = new[] { Label("label-batch-null", "BatchNull", tenantId: null) };
await scenario.Labels.SaveManyAsync(batchLabels);
Assert.Equal(Tenant.DefaultTenantId, batchLabels[0].TenantId);
Assert.Equal(Tenant.DefaultTenantId, (await scenario.Labels.FindByIdAsync("label-batch-null"))!.TenantId);
var batchAssociations = new[] { Association("assoc-batch-null", "blue", "order", "order:1", tenantId: null) };
await scenario.Associations.SaveManyAsync(batchAssociations);
Assert.Equal(Tenant.DefaultTenantId, batchAssociations[0].TenantId);
Assert.Equal("assoc-batch-null", Assert.Single(await scenario.AssociationQuery.FindByLabelIdsAsync(["blue"])).Id);
}
var agnostic = Label("label-star-2", "Star2", Tenant.AgnosticTenantId);
await scenario.Labels.SaveAsync(agnostic);
Assert.Equal(Tenant.AgnosticTenantId, agnostic.TenantId);
var stampedNamedLabel = Label("label-named-stamp", "NamedStamp", tenantId: null);
await scenario.Labels.SaveAsync(stampedNamedLabel);
Assert.Equal("tenant-a", stampedNamedLabel.TenantId);
Assert.Equal("tenant-a", (await scenario.Labels.FindByIdAsync("label-named-stamp"))!.TenantId);
var stampedNamedLabelBatch = new[] { Label("label-named-batch", "NamedBatch", tenantId: null) };
await scenario.Labels.SaveManyAsync(stampedNamedLabelBatch);
Assert.Equal("tenant-a", stampedNamedLabelBatch[0].TenantId);
Assert.Equal("tenant-a", (await scenario.Labels.FindByIdAsync("label-named-batch"))!.TenantId);
var stampedNamedAssociation = Association("assoc-named-stamp", "orange", "order", "order:1", tenantId: null);
await scenario.Associations.SaveAsync(stampedNamedAssociation);
Assert.Equal("tenant-a", stampedNamedAssociation.TenantId);
Assert.Equal("assoc-named-stamp", Assert.Single(await scenario.AssociationQuery.FindByLabelIdsAsync(["orange"])).Id);
var stampedNamedAssociationBatch = new[] { Association("assoc-named-batch", "purple", "order", "order:1", tenantId: null) };
await scenario.Associations.SaveManyAsync(stampedNamedAssociationBatch);
Assert.Equal("tenant-a", stampedNamedAssociationBatch[0].TenantId);
Assert.Equal("assoc-named-batch", Assert.Single(await scenario.AssociationQuery.FindByLabelIdsAsync(["purple"])).Id);
}
[Fact]
public async Task NormalizedNamesAreUniquePerTenant()
{
await using var scenario = await CreateScenarioAsync();
await scenario.Labels.SaveAsync(Label("label-1", "Urgent", "tenant-a"));
await scenario.AssertUniquenessConflictAsync(() => scenario.Labels.SaveAsync(Label("label-2", "urgent", "tenant-a")));
Assert.Equal("label-1", Assert.Single((await scenario.Labels.ListAsync()).Items).Id);
using (scenario.UseTenant("tenant-b"))
await scenario.Labels.SaveAsync(Label("label-b", "Urgent", "tenant-b"));
Assert.Equal("urgent", (await scenario.Labels.FindByIdAsync("label-1"))!.NormalizedName);
using (scenario.UseTenant("tenant-b"))
Assert.Equal("urgent", (await scenario.Labels.FindByIdAsync("label-b"))!.NormalizedName);
await scenario.Labels.SaveAsync(Label("label-1", "Critical", "tenant-a"));
var updated = await scenario.Labels.FindByIdAsync("label-1");
Assert.Equal("Critical", updated!.Name);
Assert.Equal("critical", updated.NormalizedName);
await scenario.Labels.SaveAsync(Label("label-later", "Later", "tenant-a"));
await scenario.AssertUniquenessConflictAsync(() => scenario.Labels.SaveAsync(Label("label-later", "Critical", "tenant-a")));
Assert.Equal("Later", (await scenario.Labels.FindByIdAsync("label-later"))!.Name);
await scenario.Labels.SaveAsync(Label("label-star", "Critical", Tenant.AgnosticTenantId));
Assert.NotNull(await scenario.Labels.FindByIdAsync("label-star"));
using (scenario.UseTenant(Tenant.DefaultTenantId))
{
await scenario.Labels.SaveAsync(Label("label-default", "Shared", tenantId: null));
await scenario.AssertUniquenessConflictAsync(() => scenario.Labels.SaveAsync(Label("label-default-dup", "Shared", tenantId: null)));
Assert.Equal(Tenant.DefaultTenantId, (await scenario.Labels.FindByIdAsync("label-default"))!.TenantId);
}
await scenario.AssertUniquenessConflictAsync(() =>
scenario.Labels.SaveManyAsync([Label("label-3", "Later", "tenant-a")]));
Assert.Null(await scenario.Labels.FindByIdAsync("label-3"));
await scenario.AssertUniquenessConflictAsync(() => scenario.Labels.SaveManyAsync(
[
Label("label-batch-1", "Invoice", "tenant-a"),
Label("label-batch-2", "invoice", "tenant-a")
]));
Assert.Null(await scenario.Labels.FindByIdAsync("label-batch-1"));
Assert.Null(await scenario.Labels.FindByIdAsync("label-batch-2"));
Assert.Equal("Later", (await scenario.Labels.FindByIdAsync("label-later"))!.Name);
}
private static async Task SeedMixedLabelsAsync(LabelStoreScenario scenario)
{
await scenario.Labels.SaveAsync(Label("label-a", "A", "tenant-a"));
await scenario.Labels.SaveAsync(Label("label-b", "B", "tenant-b"));
await scenario.Labels.SaveAsync(Label("label-star", "Star", Tenant.AgnosticTenantId));
}
private static async Task SeedMixedAssociationsAsync(LabelStoreScenario scenario)
{
await scenario.Associations.SaveAsync(Association("assoc-a", "red", "order", "order:1", "tenant-a"));
await scenario.Associations.SaveAsync(Association("assoc-b", "red", "order", "order:1", "tenant-b"));
await scenario.Associations.SaveAsync(Association("assoc-star", "red", "order", "order:1", Tenant.AgnosticTenantId));
}
private static Label Label(string id, string name, string? tenantId) =>
new()
{
Id = id,
Name = name,
TenantId = tenantId
};
private static WorkflowDefinitionLabel Association(
string id,
string labelId,
string workflowDefinitionId,
string workflowDefinitionVersionId,
string? tenantId) =>
new()
{
Id = id,
LabelId = labelId,
WorkflowDefinitionId = workflowDefinitionId,
WorkflowDefinitionVersionId = workflowDefinitionVersionId,
TenantId = tenantId
};
}
[CollectionDefinition(Name)]
public sealed class LabelStoreInMemoryConformanceCollection
{
public const string Name = "LabelStores:InMemory";
}
[CollectionDefinition(Name)]
public sealed class LabelStoreSqliteConformanceCollection
{
public const string Name = "LabelStores:EFCore.Sqlite";
}
[Collection(LabelStoreInMemoryConformanceCollection.Name)]
public sealed class InMemoryLabelStoreConformanceTests : LabelStoreConformanceTests
{
protected override Task<LabelStoreScenario> CreateScenarioAsync() => LabelStoreScenario.CreateInMemoryAsync();
}
[Collection(LabelStoreSqliteConformanceCollection.Name)]
public sealed class SqliteLabelStoreConformanceTests : LabelStoreConformanceTests
{
protected override Task<LabelStoreScenario> CreateScenarioAsync() => LabelStoreScenario.CreateSqliteAsync();
}

View file

@ -0,0 +1,132 @@
using Elsa.Common.Multitenancy;
using Elsa.Common.Services;
using Elsa.Labels.Contracts;
using Elsa.Labels.Entities;
using Elsa.Labels.Services;
using Elsa.Persistence.EFCore;
using Elsa.Persistence.EFCore.EntityHandlers;
using Elsa.Persistence.EFCore.Extensions;
using Elsa.Persistence.EFCore.Modules.Labels;
using Elsa.Persistence.EFCore.Sqlite;
using Elsa.Tenants.Options;
using Elsa.Testing.Shared.Multitenancy;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Labels.Persistence.ConformanceTests;
/// <summary>
/// Holds one InMemory or EF/SQLite pair of Labels stores and the ambient tenant they read.
/// </summary>
public sealed class LabelStoreScenario(
TestTenantAccessor tenantAccessor,
ILabelStore labels,
IWorkflowDefinitionLabelStore associations,
IWorkflowDefinitionLabelQuery associationQuery,
Func<Func<Task>, Task> assertUniquenessConflictAsync,
Func<ValueTask> disposeAsync) : IAsyncDisposable
{
public TestTenantAccessor TenantAccessor { get; } = tenantAccessor;
public ILabelStore Labels { get; } = labels;
public IWorkflowDefinitionLabelStore Associations { get; } = associations;
public IWorkflowDefinitionLabelQuery AssociationQuery { get; } = associationQuery;
public IDisposable UseTenant(string tenantId) =>
TenantAccessor.PushContext(tenantId == Tenant.DefaultTenantId
? Tenant.Default
: new Tenant { Id = tenantId, Name = tenantId });
public Task AssertUniquenessConflictAsync(Func<Task> operation) => assertUniquenessConflictAsync(operation);
public ValueTask DisposeAsync() => disposeAsync();
public static Task<LabelStoreScenario> CreateInMemoryAsync()
{
var tenantAccessor = new TestTenantAccessor("tenant-a");
var labels = new MemoryStore<Label>();
var associations = new MemoryStore<WorkflowDefinitionLabel>();
var labelStore = new InMemoryLabelStore(labels, associations, tenantAccessor);
var associationStore = new InMemoryWorkflowDefinitionLabelStore(associations, tenantAccessor);
return Task.FromResult(new LabelStoreScenario(
tenantAccessor,
labelStore,
associationStore,
associationStore,
operation => Assert.ThrowsAsync<InvalidOperationException>(operation),
() => ValueTask.CompletedTask));
}
public static async Task<LabelStoreScenario> CreateSqliteAsync()
{
var databasePath = Path.Join(Path.GetTempPath(), $"elsa-labels-conformance-{Guid.NewGuid():N}.db");
var tenantAccessor = new TestTenantAccessor("tenant-a");
ServiceProvider? services = null;
IServiceScope? scope = null;
try
{
var migrationsAssembly = typeof(LabelsDbContextFactory).Assembly;
services = new ServiceCollection()
.AddLogging()
.AddSingleton<ITenantAccessor>(tenantAccessor)
.Configure<TenantsOptions>(options => options.IsEnabled = true)
.AddScoped<IEntitySavingHandler, ApplyTenantId>()
.AddScoped<IEntityModelCreatingHandler, SetTenantIdFilter>()
.AddSqliteEntityModelCreatingHandlers()
.AddDbContextFactory<LabelsElsaDbContext>((_, builder) =>
builder.UseElsaSqlite(migrationsAssembly, $"Data Source={databasePath};Default Timeout=30"))
.Decorate<IDbContextFactory<LabelsElsaDbContext>, TenantAwareDbContextFactory<LabelsElsaDbContext>>()
.AddScoped<EntityStore<LabelsElsaDbContext, Label>>()
.AddScoped<EntityStore<LabelsElsaDbContext, WorkflowDefinitionLabel>>()
.AddScoped<EFCoreLabelStore>()
.AddScoped<EFCoreWorkflowDefinitionLabelStore>()
.BuildServiceProvider();
await using (var dbContext = await services.GetRequiredService<IDbContextFactory<LabelsElsaDbContext>>().CreateDbContextAsync())
await dbContext.Database.EnsureCreatedAsync();
scope = services.CreateScope();
var scoped = scope.ServiceProvider;
var associationStore = scoped.GetRequiredService<EFCoreWorkflowDefinitionLabelStore>();
return new(
tenantAccessor,
scoped.GetRequiredService<EFCoreLabelStore>(),
associationStore,
associationStore,
AssertSqliteUniquenessConflictAsync,
async () =>
{
scope.Dispose();
await services.DisposeAsync();
SqliteConnection.ClearAllPools();
File.Delete(databasePath);
});
}
catch
{
scope?.Dispose();
if (services is not null)
await services.DisposeAsync();
SqliteConnection.ClearAllPools();
File.Delete(databasePath);
throw;
}
}
private static async Task AssertSqliteUniquenessConflictAsync(Func<Task> operation)
{
var exception = await Record.ExceptionAsync(operation);
var sqliteException = exception switch
{
DbUpdateException { InnerException: SqliteException inner } => inner,
SqliteException direct => direct,
_ => throw new Xunit.Sdk.XunitException($"Expected a SQLite uniqueness violation, received {exception?.GetType().FullName ?? "no exception"}.")
};
Assert.Equal(19, sqliteException.SqliteErrorCode);
Assert.Contains("UNIQUE constraint failed", sqliteException.Message, StringComparison.Ordinal);
}
}