* 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>
95 lines
4.4 KiB
C#
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.");
|
|
}
|
|
}
|