elsa-core/test/unit/Elsa.Secrets.UnitTests/EFCoreSecretRepositoryTests.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

194 lines
9 KiB
C#

using Elsa.Common.Multitenancy;
using Elsa.Persistence.EFCore;
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 Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
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<SecretsElsaDbContext>(builder => builder.UseElsaSqlite(typeof(SqliteSecretsPersistenceFeatureExtensions).Assembly, connectionString));
services.AddSingleton<ISecretNameValidator, DefaultSecretNameValidator>();
services.AddScoped<Store<SecretsElsaDbContext, Secret>>();
services.AddScoped<EFCoreSecretRepository>();
_serviceProvider = services.BuildServiceProvider();
}
public async Task InitializeAsync()
{
await using var scope = _serviceProvider.CreateAsyncScope();
var factory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<SecretsElsaDbContext>>();
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 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<IDbContextFactory<SecretsElsaDbContext>>();
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<EFCoreSecretRepository>();
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<IDbContextFactory<SecretsElsaDbContext>>();
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<DbUpdateException>(() => dbContext.SaveChangesAsync());
}
[Fact]
public async Task PersistsSecretAggregate()
{
await using var scope = _serviceProvider.CreateAsyncScope();
var repository = scope.ServiceProvider.GetRequiredService<EFCoreSecretRepository>();
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<EFCoreSecretRepository>();
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<EFCoreSecretRepository>();
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<InvalidOperationException>(() => 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<EFCoreSecretRepository>();
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);
}
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 = "[]";
}
}