* 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>
156 lines
5.8 KiB
C#
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();
|
|
}
|