elsa-core/test/integration/Elsa.Labels.Persistence.ConformanceTests/LabelStoreScenario.cs
Sipke Schoorstra fe9217bdfa
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>
2026-09-13 18:58:36 +02:00

133 lines
5.6 KiB
C#

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);
}
}