elsa-core/test/unit/Elsa.Persistence.EFCore.UnitTests/LabelPerTenantUniquenessMigrationTests.cs
Sipke Schoorstra 4b15b0166d
fix(labels): enforce per-tenant uniqueness on NormalizedName (#8112)
* 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>
2026-09-13 16:16:01 +02:00

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.");
}
}