elsa-core/test/unit/Elsa.Secrets.UnitTests/SecretRepositoryScenario.cs

135 lines
5.3 KiB
C#
Raw Permalink Normal View History

test(secrets): add File/InMemory/EF tenancy conformance matrix (#8143) * test(secrets): add File/InMemory/EF tenancy conformance matrix Shared ISecretRepository scenarios for per-tenant uniqueness, Get/List isolation, deleted replace, and default-tenant duplicate rejection. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * fix(secrets): enforce default-tenant uniqueness via empty TenantId Stamp leftover null TenantId values to "" before rebuilding the per-tenant unique index, matching Labels. Fail loud on leftover duplicate names instead of silently deduping. New EF writes in the default tenant persist "". Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * fix(secrets): make default-tenant uniqueness migrate on SQLite Use the schema-less Secrets table name, let CreateIndex fail loudly when leftover duplicates remain, and isolate the tenant-aware EF model cache from the non-tenant fixture. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * test(secrets): avoid Path.Combine drop in uniqueness migration tests Reject rooted provider project paths and use Path.Join when locating Elsa.sln so CodeQL does not warn about discarded path segments. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * fix(secrets): enforce Oracle default-tenant uniqueness via NVL index Oracle stores '' as NULL, so a TenantId IS NOT NULL filtered unique index left default-tenant secret names uncovered. Use NVL(TenantId, CHR(1)) so those rows share one index key. Stamp stays for preflight grouping; leftover duplicates still fail loud with no silent dedupe. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-14 05:03:16 +00:00
using Elsa.Common.Multitenancy;
using Elsa.Persistence.EFCore;
using Elsa.Persistence.EFCore.EntityHandlers;
using Elsa.Persistence.EFCore.Extensions;
using Elsa.Secrets.Contracts;
using Elsa.Secrets.Models;
using Elsa.Secrets.Options;
using Elsa.Secrets.Persistence.EFCore;
using Elsa.Secrets.Persistence.EFCore.Repositories;
using Elsa.Secrets.Persistence.EFCore.Sqlite.Extensions;
using Elsa.Secrets.Repositories;
using Elsa.Secrets.Services;
using Elsa.Tenants.Options;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Secrets.UnitTests;
/// <summary>
/// One File, InMemory, or EF/SQLite <see cref="ISecretRepository"/> plus the ambient tenant it reads.
/// </summary>
public sealed class SecretRepositoryScenario(
ITenantAccessor tenantAccessor,
ISecretRepository repository,
Func<ValueTask> disposeAsync) : IAsyncDisposable
{
public ITenantAccessor TenantAccessor { get; } = tenantAccessor;
public ISecretRepository Repository { get; } = repository;
public IDisposable UseTenant(string tenantId) =>
TenantAccessor.PushContext(tenantId == Tenant.DefaultTenantId
? Tenant.Default
: new Tenant
{
Id = tenantId,
Name = tenantId
});
public ValueTask DisposeAsync() => disposeAsync();
public static Task<SecretRepositoryScenario> CreateInMemoryAsync()
{
var tenantAccessor = new DefaultTenantAccessor();
return Task.FromResult(new SecretRepositoryScenario(
tenantAccessor,
new InMemorySecretRepository(tenantAccessor),
() => ValueTask.CompletedTask));
}
public static Task<SecretRepositoryScenario> CreateFileAsync()
{
var path = Path.Join(Path.GetTempPath(), $"elsa-secrets-conformance-{Guid.NewGuid():N}.json");
var tenantAccessor = new DefaultTenantAccessor();
var options = Microsoft.Extensions.Options.Options.Create(new SecretsOptions
{
RepositoryFilePath = path
});
return Task.FromResult(new SecretRepositoryScenario(
tenantAccessor,
new FileSecretRepository(options, tenantAccessor: tenantAccessor),
() =>
{
if (File.Exists(path))
File.Delete(path);
return ValueTask.CompletedTask;
}));
}
public static async Task<SecretRepositoryScenario> CreateSqliteAsync()
{
var databasePath = Path.Join(Path.GetTempPath(), $"elsa-secrets-conformance-{Guid.NewGuid():N}.db");
var tenantAccessor = new DefaultTenantAccessor();
ServiceProvider? services = null;
IServiceScope? scope = null;
try
{
var migrationsAssembly = typeof(SqliteSecretsPersistenceFeatureExtensions).Assembly;
services = new ServiceCollection()
.AddSingleton<ITenantAccessor>(tenantAccessor)
.Configure<TenantsOptions>(options => options.IsEnabled = true)
.AddScoped<IEntitySavingHandler, ApplyTenantId>()
.AddScoped<IEntityModelCreatingHandler, SetTenantIdFilter>()
.AddSqliteEntityModelCreatingHandlers()
.AddDbContextFactory<SecretsElsaDbContext>((_, builder) =>
builder.UseElsaSqlite(migrationsAssembly, $"Data Source={databasePath}")
.ReplaceService<IModelCacheKeyFactory, TenantAwareSecretModelCacheKeyFactory>())
.Decorate<IDbContextFactory<SecretsElsaDbContext>, TenantAwareDbContextFactory<SecretsElsaDbContext>>()
.AddSingleton<ISecretNameValidator, DefaultSecretNameValidator>()
.AddScoped<Store<SecretsElsaDbContext, Secret>>()
.AddScoped<EFCoreSecretRepository>()
.BuildServiceProvider();
await using (var dbContext = await services.GetRequiredService<IDbContextFactory<SecretsElsaDbContext>>().CreateDbContextAsync())
await dbContext.Database.MigrateAsync();
scope = services.CreateScope();
return new(
tenantAccessor,
scope.ServiceProvider.GetRequiredService<EFCoreSecretRepository>(),
async () =>
{
scope.Dispose();
await services.DisposeAsync();
SqliteConnection.ClearAllPools();
if (File.Exists(databasePath))
File.Delete(databasePath);
});
}
catch
{
scope?.Dispose();
if (services is not null)
await services.DisposeAsync();
SqliteConnection.ClearAllPools();
if (File.Exists(databasePath))
File.Delete(databasePath);
throw;
}
}
}
/// <summary>
/// Keeps the tenant-aware Secrets model out of the default EF cache shared with
/// <c>EFCoreSecretRepositoryTests</c>, which builds the same context type without a query filter.
/// </summary>
file sealed class TenantAwareSecretModelCacheKeyFactory : IModelCacheKeyFactory
{
public object Create(DbContext context, bool designTime) =>
(context.GetType(), designTime, "secrets-tenant-aware");
}