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.Persistence.EFCore; using Elsa.Secrets.Persistence.EFCore.Repositories; using Elsa.Secrets.Persistence.EFCore.Sqlite.Extensions; using Elsa.Secrets.Services; using Elsa.Tenants.Options; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Xunit; namespace Elsa.Secrets.UnitTests; public class EFCoreSecretRepositoryTests : IAsyncLifetime { private readonly string _databasePath = Path.Join(Path.GetTempPath(), $"elsa-secrets-{Guid.NewGuid():N}.db"); private readonly ServiceProvider _serviceProvider; public EFCoreSecretRepositoryTests() { var services = new ServiceCollection(); var connectionString = $"Data Source={_databasePath}"; services.AddSqliteEntityModelCreatingHandlers(); services.AddDbContextFactory(builder => builder.UseElsaSqlite(typeof(SqliteSecretsPersistenceFeatureExtensions).Assembly, connectionString)); services.AddSingleton(); services.AddScoped>(); services.AddScoped(); _serviceProvider = services.BuildServiceProvider(); } public async Task InitializeAsync() { await using var scope = _serviceProvider.CreateAsyncScope(); var factory = scope.ServiceProvider.GetRequiredService>(); await using var dbContext = await factory.CreateDbContextAsync(); await dbContext.Database.MigrateAsync(); } public async Task DisposeAsync() { await _serviceProvider.DisposeAsync(); if (File.Exists(_databasePath)) File.Delete(_databasePath); } [Fact] public void Repository_RetainsPreTenancyConstructorShape() { Assert.NotNull(typeof(EFCoreSecretRepository).GetConstructor([ typeof(Store), typeof(ISecretNameValidator)])); Assert.NotNull(typeof(EFCoreSecretRepository).GetConstructor([ typeof(Store), typeof(ISecretNameValidator), typeof(IOptions)])); } [Fact] public async Task SecretNamesAreUniquePerTenantRatherThanGlobally() { // Asserted against the schema rather than through the repository. The repository's own duplicate-name // check queries dbContext.Secrets, which the global query filter already scopes to the ambient tenant // when multitenancy is on -- so exercising it here would test SetTenantIdFilter, not this change. What // this change owns is the index, and a global unique index would make the name a shared resource: the // second tenant to want "smtp:password" could not create one. await using var scope = _serviceProvider.CreateAsyncScope(); var factory = scope.ServiceProvider.GetRequiredService>(); await using var dbContext = await factory.CreateDbContextAsync(); var index = dbContext.Model .FindEntityType(typeof(Secret))! .GetIndexes() .Single(x => x.IsUnique); Assert.Equal(["TenantId", SecretShadowPropertyNames.NormalizedName], index.Properties.Select(x => x.Name)); } [Fact] public async Task DefaultTenantWritesUseEmptyTenantId() { // Default-tenant uniqueness uses "" so the composite unique index covers these rows. // Leftover nulls from SecretTenancy are stamped by SecretDefaultTenantUniqueness. await using var scope = _serviceProvider.CreateAsyncScope(); var repository = scope.ServiceProvider.GetRequiredService(); await repository.AddAsync(new Secret { Name = "legacy:secret", DisplayName = "Legacy" }); var stored = await repository.GetAsync("legacy:secret"); Assert.NotNull(stored); Assert.Equal(string.Empty, stored!.TenantId); } [Fact] public async Task UniqueIndexRejectsDuplicateDefaultTenantNames() { await using var scope = _serviceProvider.CreateAsyncScope(); var factory = scope.ServiceProvider.GetRequiredService>(); await using var dbContext = await factory.CreateDbContextAsync(); await InsertSecretAsync(dbContext, "first", "smtp:password", Tenant.DefaultTenantId); await dbContext.SaveChangesAsync(); await InsertSecretAsync(dbContext, "second", "smtp:password", Tenant.DefaultTenantId); await Assert.ThrowsAsync(() => dbContext.SaveChangesAsync()); } [Fact] public async Task PersistsSecretAggregate() { await using var scope = _serviceProvider.CreateAsyncScope(); var repository = scope.ServiceProvider.GetRequiredService(); var secret = new Secret { Name = "smtp:password", DisplayName = "SMTP password", Tags = ["API-Key"], Versions = { new SecretVersion { Version = 1, Payload = new SecretPayload { Metadata = { ["ProtectedValue"] = "ciphertext" } } } } }; await repository.AddAsync(secret); var reloaded = await repository.GetAsync("smtp:password"); Assert.NotNull(reloaded); Assert.Equal("SMTP password", reloaded.DisplayName); Assert.Contains("api-key", reloaded.Tags); Assert.True(reloaded.Versions.Single().Payload.Metadata.ContainsKey("protectedvalue")); Assert.Equal(1, reloaded.Versions.Single().Version); } [Fact] public async Task TryAddOrReplaceDeletedAsync_ReplacesOnlyDeletedSecret() { await using var scope = _serviceProvider.CreateAsyncScope(); var repository = scope.ServiceProvider.GetRequiredService(); await repository.AddAsync(new Secret { Name = "smtp:password", DisplayName = "SMTP password" }); var activeReplacementResult = await repository.TryAddOrReplaceDeletedAsync(new Secret { Name = "smtp:password", DisplayName = "Active replacement" }); await repository.SaveAsync(new Secret { Name = "smtp:password", DisplayName = "Deleted password", Status = SecretStatus.Deleted }); var deletedReplacementResult = await repository.TryAddOrReplaceDeletedAsync(new Secret { Name = "smtp:password", DisplayName = "Replacement password" }); var reloaded = await repository.GetAsync("smtp:password"); Assert.False(activeReplacementResult); Assert.True(deletedReplacementResult); Assert.NotNull(reloaded); Assert.Equal("Replacement password", reloaded.DisplayName); Assert.Equal(SecretStatus.Active, reloaded.Status); } [Fact] public async Task NameLookups_AreCaseInsensitive() { await using var scope = _serviceProvider.CreateAsyncScope(); var repository = scope.ServiceProvider.GetRequiredService(); await repository.AddAsync(new Secret { Name = "SMTP:PASSWORD", DisplayName = "SMTP password" }); var reloaded = await repository.GetAsync("smtp:password"); var whitespaceReloaded = await repository.GetAsync(" SMTP:PASSWORD "); var activeReplacementResult = await repository.TryAddOrReplaceDeletedAsync(new Secret { Name = "smtp:password", DisplayName = "Replacement password" }); var duplicateException = await Assert.ThrowsAsync(() => repository.AddAsync(new Secret { Name = "smtp:password", DisplayName = "Duplicate password" })); Assert.NotNull(reloaded); Assert.NotNull(whitespaceReloaded); Assert.Equal("SMTP:PASSWORD", reloaded.Name); Assert.Equal(reloaded.Id, whitespaceReloaded.Id); Assert.False(activeReplacementResult); Assert.Equal("A secret named 'smtp:password' already exists.", duplicateException.Message); } [Fact] public async Task TryAddOrReplaceDeletedAsync_WhenReplacingDeletedSecret_PersistsReplacementId() { await using var scope = _serviceProvider.CreateAsyncScope(); var repository = scope.ServiceProvider.GetRequiredService(); await repository.SaveAsync(new Secret { Id = "old", Name = "smtp:password", DisplayName = "Deleted password", Status = SecretStatus.Deleted }); var replacement = new Secret { Id = "new", Name = "SMTP:PASSWORD", DisplayName = "Replacement password" }; var result = await repository.TryAddOrReplaceDeletedAsync(replacement); var reloaded = await repository.GetAsync("smtp:password"); Assert.True(result); Assert.NotNull(reloaded); Assert.Equal("new", reloaded.Id); Assert.Equal("Replacement password", reloaded.DisplayName); Assert.Equal(SecretStatus.Active, reloaded.Status); } [Fact] public async Task TryAddOrReplaceDeletedAsync_WhenIncomingTenantDiffers_PreservesExistingTenant() { await WithTenantAwareRepositoryAsync(async (repository, tenantAccessor) => { using (UseTenant(tenantAccessor, "tenant-a")) { await repository.SaveAsync(new Secret { Id = "old", Name = "smtp:password", DisplayName = "Deleted password", Status = SecretStatus.Deleted }); var replacement = new Secret { Id = "new", Name = "SMTP:PASSWORD", DisplayName = "Replacement password", TenantId = "tenant-b" }; var result = await repository.TryAddOrReplaceDeletedAsync(replacement); var reloaded = await repository.GetAsync("smtp:password"); Assert.True(result); Assert.Equal("tenant-a", replacement.TenantId); Assert.NotNull(reloaded); Assert.Equal("new", reloaded!.Id); Assert.Equal("Replacement password", reloaded.DisplayName); Assert.Equal("tenant-a", reloaded.TenantId); } using (UseTenant(tenantAccessor, "tenant-b")) Assert.Null(await repository.GetAsync("smtp:password")); }); } [Fact] public async Task SaveAsync_WhenIncomingTenantDiffers_PreservesExistingTenant() { await WithTenantAwareRepositoryAsync(async (repository, tenantAccessor) => { using (UseTenant(tenantAccessor, "tenant-a")) { await repository.SaveAsync(new Secret { Id = "old", Name = "smtp:password", DisplayName = "Original" }); await repository.SaveAsync(new Secret { Id = "new", Name = "SMTP:PASSWORD", DisplayName = "Updated", TenantId = "tenant-b" }); var reloaded = await repository.GetAsync("smtp:password"); Assert.NotNull(reloaded); Assert.Equal("old", reloaded!.Id); Assert.Equal("Updated", reloaded.DisplayName); Assert.Equal("tenant-a", reloaded.TenantId); } using (UseTenant(tenantAccessor, "tenant-b")) Assert.Null(await repository.GetAsync("smtp:password")); }); } [Fact] public async Task TryAddOrReplaceDeletedAsync_WhenTenancyIsDisabled_PreservesLegacyReplacementBehavior() { await using var scope = _serviceProvider.CreateAsyncScope(); var repository = scope.ServiceProvider.GetRequiredService(); await repository.SaveAsync(new Secret { Id = "old", Name = "smtp:password", DisplayName = "Deleted password", Status = SecretStatus.Deleted, TenantId = Tenant.AgnosticTenantId }); var replacement = new Secret { Id = "new", Name = "SMTP:PASSWORD", DisplayName = "Replacement password", TenantId = "tenant-b" }; Assert.True(await repository.TryAddOrReplaceDeletedAsync(replacement)); var reloaded = await repository.GetAsync("smtp:password"); Assert.NotNull(reloaded); Assert.Equal("tenant-b", reloaded!.TenantId); Assert.Equal("new", reloaded.Id); } [Fact] public async Task SaveAsync_WhenTenancyIsDisabled_KeepsStoredIdButAcceptsIncomingTenant() { await using var scope = _serviceProvider.CreateAsyncScope(); var repository = scope.ServiceProvider.GetRequiredService(); await repository.AddAsync(new Secret { Id = "stored-id", Name = "save:secret", DisplayName = "Original", TenantId = "tenant-a" }); var incoming = new Secret { Id = "incoming-id", Name = " SAVE:SECRET ", DisplayName = "Updated", TenantId = "tenant-b" }; await repository.SaveAsync(incoming); var stored = await repository.GetAsync("save:secret"); Assert.NotNull(stored); Assert.Equal("stored-id", stored!.Id); Assert.Equal("tenant-b", stored.TenantId); } [Fact] public async Task SaveAsync_RejectsNamedWriterUpdatingAgnosticSecretWithoutMutation() { await WithTenantAwareRepositoryAsync(async (repository, tenantAccessor) => { using (UseTenant(tenantAccessor, Tenant.AgnosticTenantId)) { await repository.SaveAsync(new Secret { Id = "agnostic-id", Name = "smtp:password", DisplayName = "Agnostic secret", TenantId = Tenant.AgnosticTenantId }); } using (UseTenant(tenantAccessor, "tenant-a")) { var exception = await Assert.ThrowsAsync(() => repository.SaveAsync(new Secret { Id = "replacement-id", Name = "SMTP:PASSWORD", DisplayName = "Forged update" })); Assert.Equal("A secret named 'SMTP:PASSWORD' belongs to another tenant.", exception.Message); var unchanged = await repository.GetAsync("smtp:password"); Assert.NotNull(unchanged); Assert.Equal("agnostic-id", unchanged!.Id); Assert.Equal("Agnostic secret", unchanged.DisplayName); Assert.Equal(Tenant.AgnosticTenantId, unchanged.TenantId); } }); } [Fact] public async Task TryAddOrReplaceDeletedAsync_RejectsNamedWriterReplacingAgnosticSecretWithoutMutation() { await WithTenantAwareRepositoryAsync(async (repository, tenantAccessor) => { using (UseTenant(tenantAccessor, Tenant.AgnosticTenantId)) { await repository.SaveAsync(new Secret { Id = "agnostic-id", Name = "smtp:password", DisplayName = "Deleted agnostic secret", Status = SecretStatus.Deleted, TenantId = Tenant.AgnosticTenantId }); } using (UseTenant(tenantAccessor, "tenant-a")) { var result = await repository.TryAddOrReplaceDeletedAsync(new Secret { Id = "replacement-id", Name = "SMTP:PASSWORD", DisplayName = "Forged replacement" }); Assert.False(result); var unchanged = await repository.GetAsync("smtp:password"); Assert.NotNull(unchanged); Assert.Equal("agnostic-id", unchanged!.Id); Assert.Equal("Deleted agnostic secret", unchanged.DisplayName); Assert.Equal(SecretStatus.Deleted, unchanged.Status); Assert.Equal(Tenant.AgnosticTenantId, unchanged.TenantId); } }); } [Fact] public async Task TryAddOrReplaceDeletedAsync_AllowsAgnosticWriterReplacingAgnosticSecret() { await WithTenantAwareRepositoryAsync(async (repository, tenantAccessor) => { using (UseTenant(tenantAccessor, Tenant.AgnosticTenantId)) { await repository.SaveAsync(new Secret { Id = "agnostic-id", Name = "smtp:password", DisplayName = "Deleted agnostic secret", Status = SecretStatus.Deleted, TenantId = Tenant.AgnosticTenantId }); var result = await repository.TryAddOrReplaceDeletedAsync(new Secret { Id = "replacement-id", Name = "SMTP:PASSWORD", DisplayName = "Replacement agnostic secret" }); Assert.True(result); var replacement = await repository.GetAsync("smtp:password"); Assert.NotNull(replacement); Assert.Equal("replacement-id", replacement!.Id); Assert.Equal("Replacement agnostic secret", replacement.DisplayName); Assert.Equal(Tenant.AgnosticTenantId, replacement.TenantId); } }); } [Fact] public async Task PreTenancyConstructor_UsesTenantFilteredModelForOwnershipChecks() { await WithTenantAwareRepositoryAsync(async (repository, tenantAccessor) => { using (UseTenant(tenantAccessor, Tenant.AgnosticTenantId)) { await repository.SaveAsync(new Secret { Id = "agnostic-id", Name = "smtp:password", DisplayName = "Deleted agnostic secret", Status = SecretStatus.Deleted, TenantId = Tenant.AgnosticTenantId }); } using (UseTenant(tenantAccessor, "tenant-a")) { var result = await repository.TryAddOrReplaceDeletedAsync(new Secret { Id = "replacement-id", Name = "SMTP:PASSWORD", DisplayName = "Forged replacement" }); Assert.False(result); var unchanged = await repository.GetAsync("smtp:password"); Assert.NotNull(unchanged); Assert.Equal("agnostic-id", unchanged!.Id); Assert.Equal(SecretStatus.Deleted, unchanged.Status); } }, useLegacyConstructor: true); } private static async Task WithTenantAwareRepositoryAsync(Func test, bool useLegacyConstructor = false) { var databasePath = Path.Join(Path.GetTempPath(), $"elsa-secrets-tenant-{Guid.NewGuid():N}.db"); var tenantAccessor = new DefaultTenantAccessor(); var services = new ServiceCollection() .AddSqliteEntityModelCreatingHandlers() .AddSingleton(tenantAccessor) .Configure(options => options.IsEnabled = true) .AddScoped() .AddScoped() .AddDbContextFactory(builder => builder .EnableServiceProviderCaching(false) .UseElsaSqlite(typeof(SqliteSecretsPersistenceFeatureExtensions).Assembly, $"Data Source={databasePath}")) .Decorate, TenantAwareDbContextFactory>() .AddSingleton() .AddScoped>() .AddScoped() .BuildServiceProvider(); try { await using var scope = services.CreateAsyncScope(); var factory = scope.ServiceProvider.GetRequiredService>(); await using (var dbContext = await factory.CreateDbContextAsync()) await dbContext.Database.MigrateAsync(); var repository = useLegacyConstructor ? new EFCoreSecretRepository( scope.ServiceProvider.GetRequiredService>(), scope.ServiceProvider.GetRequiredService()) : scope.ServiceProvider.GetRequiredService(); await test(repository, tenantAccessor); } finally { await services.DisposeAsync(); if (File.Exists(databasePath)) File.Delete(databasePath); } } private static IDisposable UseTenant(DefaultTenantAccessor tenantAccessor, string tenantId) => tenantAccessor.PushContext(new Tenant { Id = tenantId, Name = tenantId }); private static async Task InsertSecretAsync(SecretsElsaDbContext dbContext, string id, string name, string tenantId) { var secret = new Secret { Id = id, Name = name, DisplayName = name, TenantId = tenantId }; await dbContext.Secrets.AddAsync(secret); dbContext.Entry(secret).Property(SecretShadowPropertyNames.NormalizedName).CurrentValue = name; dbContext.Entry(secret).Property(SecretShadowPropertyNames.SerializedTags).CurrentValue = "[]"; dbContext.Entry(secret).Property(SecretShadowPropertyNames.SerializedVersions).CurrentValue = "[]"; } }