* fix(labels): enforce per-tenant uniqueness on NormalizedName Finish the unused Label.NormalizedName contract the same way Secrets does: unique (TenantId, NormalizedName) in EF, fail-closed Memory saves, and keep NormalizedName in sync with Name. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * fix(labels): clone Memory label reads so a rejected rename cannot persist Find/List handed out live store refs. Labels.Update mutates Name (and NormalizedName) on that instance before Save; a uniqueness rejection then left the stored row already renamed. Clone-on-read matches Memory identity stores from #8108. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * fix(labels): stamp null TenantId, fail-loud on leftover duplicates, cap names at 255 Follow the Architect steer for Greptile P1s: do not auto-delete duplicate labels; UPDATE Labels SET TenantId = '' WHERE TenantId IS NULL on every provider before CreateIndex; keep SQL Server/Oracle filtered unique indexes; HasMaxLength(255) on Name and NormalizedName with no silent truncate. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * fix(labels): fail-loud preflight for leftover keys and over-length names No silent dedupe: every provider lists leftover (TenantId, NormalizedName) keys and aborts before CreateIndex. SQL Server/Oracle keep their filtered unique indexes. PostgreSQL/SQLite keep provider column types. Providers that narrow Name/NormalizedName to 255 preflight over-length Ids first. Memory uniqueness semantics are unchanged. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * test(labels): match Oracle filtered-index escape in migration assertion Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
102 lines
4.6 KiB
C#
102 lines
4.6 KiB
C#
namespace Elsa.Persistence.EFCore.UnitTests;
|
|
|
|
/// <summary>
|
|
/// PerTenantLabelUniqueness must stamp null TenantId to "" and must not
|
|
/// auto-delete duplicate labels (associations would be at risk). A preflight
|
|
/// lists leftover keys and aborts; CreateIndex then fails loudly if any remain.
|
|
/// SQL Server/MySQL/Oracle also preflight over-length Name/NormalizedName
|
|
/// before narrowing those columns. PostgreSQL/SQLite keep provider column types.
|
|
/// </summary>
|
|
public class LabelPerTenantUniquenessMigrationTests
|
|
{
|
|
[Theory]
|
|
[InlineData("Elsa.Persistence.EFCore.Sqlite")]
|
|
[InlineData("Elsa.Persistence.EFCore.SqlServer")]
|
|
[InlineData("Elsa.Persistence.EFCore.PostgreSql")]
|
|
[InlineData("Elsa.Persistence.EFCore.MySql")]
|
|
[InlineData("Elsa.Persistence.EFCore.Oracle")]
|
|
public void PerTenantLabelUniqueness_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);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Elsa.Persistence.EFCore.Sqlite", "RAISE")]
|
|
[InlineData("Elsa.Persistence.EFCore.SqlServer", "THROW")]
|
|
[InlineData("Elsa.Persistence.EFCore.PostgreSql", "RAISE EXCEPTION")]
|
|
[InlineData("Elsa.Persistence.EFCore.MySql", "SIGNAL")]
|
|
[InlineData("Elsa.Persistence.EFCore.Oracle", "RAISE_APPLICATION_ERROR")]
|
|
public void PerTenantLabelUniqueness_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);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Elsa.Persistence.EFCore.SqlServer")]
|
|
[InlineData("Elsa.Persistence.EFCore.MySql")]
|
|
[InlineData("Elsa.Persistence.EFCore.Oracle")]
|
|
public void PerTenantLabelUniqueness_PreflightsOverLengthRowsBeforeAlter(string providerProject)
|
|
{
|
|
var migration = FindMigration(providerProject);
|
|
|
|
Assert.Contains("255", migration, StringComparison.Ordinal);
|
|
Assert.Contains("Over-length", migration, StringComparison.OrdinalIgnoreCase);
|
|
Assert.Contains("AlterColumn", migration, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Elsa.Persistence.EFCore.Sqlite")]
|
|
[InlineData("Elsa.Persistence.EFCore.PostgreSql")]
|
|
public void PerTenantLabelUniqueness_KeepsUnboundedProviderColumnTypes(string providerProject)
|
|
{
|
|
var migration = FindMigration(providerProject);
|
|
|
|
Assert.DoesNotContain("AlterColumn", migration, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("varchar(255)", migration, StringComparison.OrdinalIgnoreCase);
|
|
Assert.DoesNotContain("nvarchar(255)", migration, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Elsa.Persistence.EFCore.SqlServer", "[TenantId] IS NOT NULL")]
|
|
[InlineData("Elsa.Persistence.EFCore.Oracle", "\\\"TenantId\\\" IS NOT NULL")]
|
|
public void PerTenantLabelUniqueness_KeepsFilteredUniqueIndex(string providerProject, string filter)
|
|
{
|
|
var migration = FindMigration(providerProject);
|
|
Assert.Contains(filter, migration, StringComparison.Ordinal);
|
|
}
|
|
|
|
private static string FindMigration(string providerProject)
|
|
{
|
|
var repoRoot = FindRepoRoot();
|
|
var labelsDir = Path.Combine(repoRoot, "src", "modules", providerProject, "Migrations", "Labels");
|
|
var path = Directory.GetFiles(labelsDir, "*PerTenantLabelUniqueness.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.Combine(directory.FullName, "Elsa.sln")))
|
|
return directory.FullName;
|
|
|
|
directory = directory.Parent;
|
|
}
|
|
|
|
throw new InvalidOperationException("Could not find Elsa.sln from the test output directory.");
|
|
}
|
|
}
|