elsa-core/test/unit/Elsa.Secrets.UnitTests/SecretRepositoryScenario.cs
Sipke Schoorstra 440ffaf719
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 07:03:16 +02:00

135 lines
5.3 KiB
C#

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