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

95 lines
4.4 KiB
C#

namespace Elsa.Secrets.UnitTests;
/// <summary>
/// SecretDefaultTenantUniqueness must stamp null TenantId to "" and must not
/// auto-delete duplicate secrets. A preflight lists leftover keys and aborts;
/// CreateIndex then fails loudly if any remain. SQL Server keeps the filtered
/// unique index (TenantId IS NOT NULL) after leftover nulls become "". Oracle
/// stores '' as NULL, so it uses NVL(TenantId, CHR(1)) instead of a filter.
/// </summary>
public class SecretDefaultTenantUniquenessMigrationTests
{
[Theory]
[InlineData("Elsa.Secrets.Persistence.EFCore.Sqlite")]
[InlineData("Elsa.Secrets.Persistence.EFCore.SqlServer")]
[InlineData("Elsa.Secrets.Persistence.EFCore.PostgreSql")]
[InlineData("Elsa.Secrets.Persistence.EFCore.MySql")]
[InlineData("Elsa.Secrets.Persistence.EFCore.Oracle")]
public void SecretDefaultTenantUniqueness_StampsNullTenantIdAndDoesNotDeleteDuplicates(string providerProject)
{
var migration = FindMigration(providerProject);
Assert.Contains("TenantId", migration, StringComparison.Ordinal);
Assert.Contains("IS NULL", migration, StringComparison.Ordinal);
Assert.DoesNotContain("DELETE FROM", migration, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("DeleteData", migration, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("COALESCE", migration, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void SecretDefaultTenantUniqueness_SqliteFailsLoudlyAtCreateIndex()
{
// SQLite cannot abort from a standalone SELECT, so CreateIndex unique is the fail-loud path.
var migration = FindMigration("Elsa.Secrets.Persistence.EFCore.Sqlite");
Assert.DoesNotContain("RAISE", migration, StringComparison.Ordinal);
Assert.Contains("CreateIndex", migration, StringComparison.Ordinal);
Assert.Contains("unique: true", migration, StringComparison.Ordinal);
}
[Theory]
[InlineData("Elsa.Secrets.Persistence.EFCore.SqlServer", "THROW")]
[InlineData("Elsa.Secrets.Persistence.EFCore.PostgreSql", "RAISE EXCEPTION")]
[InlineData("Elsa.Secrets.Persistence.EFCore.MySql", "SIGNAL")]
[InlineData("Elsa.Secrets.Persistence.EFCore.Oracle", "RAISE_APPLICATION_ERROR")]
public void SecretDefaultTenantUniqueness_PreflightsDuplicateKeys(string providerProject, string abortKeyword)
{
var migration = FindMigration(providerProject);
Assert.Contains(abortKeyword, migration, StringComparison.Ordinal);
Assert.Contains("COUNT(*)", migration, StringComparison.Ordinal);
Assert.Contains("Duplicate keys", migration, StringComparison.OrdinalIgnoreCase);
Assert.Contains("HAVING COUNT(*) > 1", migration, StringComparison.Ordinal);
}
[Fact]
public void SecretDefaultTenantUniqueness_SqlServerKeepsFilteredUniqueIndex()
{
var migration = FindMigration("Elsa.Secrets.Persistence.EFCore.SqlServer");
Assert.Contains("[TenantId] IS NOT NULL", migration, StringComparison.Ordinal);
}
[Fact]
public void SecretDefaultTenantUniqueness_OracleUsesNvlBecauseEmptyStringIsNull()
{
var migration = FindMigration("Elsa.Secrets.Persistence.EFCore.Oracle");
Assert.Contains("NVL(\"TenantId\", CHR(1))", migration, StringComparison.Ordinal);
Assert.Contains("CREATE UNIQUE INDEX", migration, StringComparison.Ordinal);
}
private static string FindMigration(string providerProject)
{
if (Path.IsPathRooted(providerProject))
throw new ArgumentException("Provider project must be a relative path.", nameof(providerProject));
var repoRoot = FindRepoRoot();
var secretsDir = Path.Combine(repoRoot, "src", "modules", providerProject, "Migrations", "Secrets");
var path = Directory.GetFiles(secretsDir, "*SecretDefaultTenantUniqueness.cs")
.Single(file => !file.EndsWith(".Designer.cs", StringComparison.Ordinal));
return File.ReadAllText(path);
}
private static string FindRepoRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
if (File.Exists(Path.Join(directory.FullName, "Elsa.sln")))
return directory.FullName;
directory = directory.Parent;
}
throw new InvalidOperationException("Could not find Elsa.sln from the test output directory.");
}
}