* 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>
135 lines
5.3 KiB
C#
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");
|
|
}
|