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; /// /// One File, InMemory, or EF/SQLite plus the ambient tenant it reads. /// public sealed class SecretRepositoryScenario( ITenantAccessor tenantAccessor, ISecretRepository repository, Func 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 CreateInMemoryAsync() { var tenantAccessor = new DefaultTenantAccessor(); return Task.FromResult(new SecretRepositoryScenario( tenantAccessor, new InMemorySecretRepository(tenantAccessor), () => ValueTask.CompletedTask)); } public static Task 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 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(tenantAccessor) .Configure(options => options.IsEnabled = true) .AddScoped() .AddScoped() .AddSqliteEntityModelCreatingHandlers() .AddDbContextFactory((_, builder) => builder.UseElsaSqlite(migrationsAssembly, $"Data Source={databasePath}") .ReplaceService()) .Decorate, TenantAwareDbContextFactory>() .AddSingleton() .AddScoped>() .AddScoped() .BuildServiceProvider(); await using (var dbContext = await services.GetRequiredService>().CreateDbContextAsync()) await dbContext.Database.MigrateAsync(); scope = services.CreateScope(); return new( tenantAccessor, scope.ServiceProvider.GetRequiredService(), 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; } } } /// /// Keeps the tenant-aware Secrets model out of the default EF cache shared with /// EFCoreSecretRepositoryTests, which builds the same context type without a query filter. /// file sealed class TenantAwareSecretModelCacheKeyFactory : IModelCacheKeyFactory { public object Create(DbContext context, bool designTime) => (context.GetType(), designTime, "secrets-tenant-aware"); }