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

156 lines
5.8 KiB
C#

using Elsa.Common.Multitenancy;
using Elsa.Secrets.Models;
namespace Elsa.Secrets.UnitTests;
/// <summary>
/// Shared File / InMemory / EF Core store-contract assertions for secret repositories.
/// Covers per-tenant name uniqueness and ambient-tenant isolation on the existing
/// <see cref="Elsa.Secrets.Contracts.ISecretRepository"/> surface.
/// </summary>
public abstract class SecretRepositoryConformanceTests
{
protected abstract Task<SecretRepositoryScenario> CreateScenarioAsync();
[Fact]
public async Task TwoTenantsCanOwnTheSameSecretName()
{
await using var scenario = await CreateScenarioAsync();
using (scenario.UseTenant("tenant-a"))
{
var secretA = CreateSecret("SMTP:PASSWORD", "Tenant A");
await scenario.Repository.AddAsync(secretA);
Assert.Equal("tenant-a", secretA.TenantId);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
scenario.Repository.AddAsync(CreateSecret("smtp:password", "Duplicate A")));
}
using (scenario.UseTenant("tenant-b"))
{
var secretB = CreateSecret("smtp:password", "Tenant B");
await scenario.Repository.AddAsync(secretB);
Assert.Equal("tenant-b", secretB.TenantId);
Assert.Equal("Tenant B", (await scenario.Repository.GetAsync("SMTP:PASSWORD"))!.DisplayName);
}
using (scenario.UseTenant("tenant-a"))
Assert.Equal("Tenant A", (await scenario.Repository.GetAsync("smtp:password"))!.DisplayName);
}
[Fact]
public async Task GetDoesNotCrossReadAnotherTenant()
{
await using var scenario = await CreateScenarioAsync();
using (scenario.UseTenant("tenant-a"))
await scenario.Repository.AddAsync(CreateSecret("smtp:password", "Tenant A"));
using (scenario.UseTenant("tenant-b"))
{
Assert.Null(await scenario.Repository.GetAsync("smtp:password"));
await scenario.Repository.AddAsync(CreateSecret("smtp:password", "Tenant B"));
}
using (scenario.UseTenant("tenant-a"))
{
var loaded = await scenario.Repository.GetAsync("SMTP:PASSWORD");
Assert.NotNull(loaded);
Assert.Equal("Tenant A", loaded.DisplayName);
Assert.Equal("tenant-a", loaded.TenantId);
}
}
[Fact]
public async Task ListIsTenantScoped()
{
await using var scenario = await CreateScenarioAsync();
using (scenario.UseTenant("tenant-a"))
{
await scenario.Repository.AddAsync(CreateSecret("smtp:password", "Tenant A"));
await scenario.Repository.AddAsync(CreateSecret("api:key", "Key A"));
}
using (scenario.UseTenant("tenant-b"))
{
await scenario.Repository.AddAsync(CreateSecret("smtp:password", "Tenant B"));
var listedB = await scenario.Repository.ListAsync();
Assert.Equal("Tenant B", Assert.Single(listedB).DisplayName);
}
using (scenario.UseTenant("tenant-a"))
{
var listedA = (await scenario.Repository.ListAsync()).OrderBy(x => x.Name).ToList();
Assert.Equal(["api:key", "smtp:password"], listedA.Select(x => x.Name).ToList());
Assert.All(listedA, secret => Assert.Equal("tenant-a", secret.TenantId));
}
}
[Fact]
public async Task DeletedReplaceStaysPerTenant()
{
await using var scenario = await CreateScenarioAsync();
using (scenario.UseTenant("tenant-a"))
{
await scenario.Repository.SaveAsync(CreateSecret("smtp:password", "Deleted A", SecretStatus.Deleted));
Assert.True(await scenario.Repository.TryAddOrReplaceDeletedAsync(CreateSecret("SMTP:PASSWORD", "Replacement A")));
Assert.Equal("Replacement A", (await scenario.Repository.GetAsync("smtp:password"))!.DisplayName);
}
using (scenario.UseTenant("tenant-b"))
{
Assert.True(await scenario.Repository.TryAddOrReplaceDeletedAsync(CreateSecret("smtp:password", "Tenant B")));
Assert.Equal("Tenant B", (await scenario.Repository.GetAsync("smtp:password"))!.DisplayName);
}
using (scenario.UseTenant("tenant-a"))
Assert.Equal("Replacement A", (await scenario.Repository.GetAsync("smtp:password"))!.DisplayName);
}
[Fact]
public async Task DefaultTenantRejectsDuplicateNames()
{
await using var scenario = await CreateScenarioAsync();
using (scenario.UseTenant(Tenant.DefaultTenantId))
{
var secret = CreateSecret("smtp:password", "Default");
await scenario.Repository.AddAsync(secret);
Assert.Equal(Tenant.DefaultTenantId, secret.TenantId);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
scenario.Repository.AddAsync(CreateSecret("SMTP:PASSWORD", "Duplicate")));
}
}
private static Secret CreateSecret(string name, string displayName, SecretStatus status = SecretStatus.Active) =>
new()
{
Name = name,
DisplayName = displayName,
Status = status
};
}
public sealed class InMemorySecretRepositoryConformanceTests : SecretRepositoryConformanceTests
{
protected override Task<SecretRepositoryScenario> CreateScenarioAsync() =>
SecretRepositoryScenario.CreateInMemoryAsync();
}
public sealed class FileSecretRepositoryConformanceTests : SecretRepositoryConformanceTests
{
protected override Task<SecretRepositoryScenario> CreateScenarioAsync() =>
SecretRepositoryScenario.CreateFileAsync();
}
public sealed class SqliteSecretRepositoryConformanceTests : SecretRepositoryConformanceTests
{
protected override Task<SecretRepositoryScenario> CreateScenarioAsync() =>
SecretRepositoryScenario.CreateSqliteAsync();
}