Harden EF Core secret name uniqueness

This commit is contained in:
Sipke Schoorstra 2026-06-01 20:57:11 +02:00
parent 8c0a720056
commit 9fac0c83c0
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
18 changed files with 186 additions and 96 deletions

View file

@ -47,6 +47,11 @@ namespace Elsa.Secrets.Persistence.EFCore.MySql.Migrations.Secrets
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<string>("Scope")
.HasMaxLength(200)
.HasColumnType("varchar(200)");
@ -81,9 +86,9 @@ namespace Elsa.Secrets.Persistence.EFCore.MySql.Migrations.Secrets
b.HasKey("Id");
b.HasIndex("Name")
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("IX_Secret_Name");
.HasDatabaseName("IX_Secret_NormalizedName");
b.HasIndex("Scope")
.HasDatabaseName("IX_Secret_Scope");

View file

@ -34,6 +34,8 @@ namespace Elsa.Secrets.Persistence.EFCore.MySql.Migrations.Secrets
.Annotation("MySql:CharSet", "utf8mb4"),
Name = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
NormalizedName = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
DisplayName = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Description = table.Column<string>(type: "longtext", nullable: true)
@ -60,10 +62,10 @@ namespace Elsa.Secrets.Persistence.EFCore.MySql.Migrations.Secrets
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_Secret_Name",
name: "IX_Secret_NormalizedName",
schema: _schema.Schema,
table: "Secrets",
column: "Name",
column: "NormalizedName",
unique: true);
migrationBuilder.CreateIndex(

View file

@ -44,6 +44,11 @@ namespace Elsa.Secrets.Persistence.EFCore.MySql.Migrations.Secrets
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<string>("Scope")
.HasMaxLength(200)
.HasColumnType("varchar(200)");
@ -78,9 +83,9 @@ namespace Elsa.Secrets.Persistence.EFCore.MySql.Migrations.Secrets
b.HasKey("Id");
b.HasIndex("Name")
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("IX_Secret_Name");
.HasDatabaseName("IX_Secret_NormalizedName");
b.HasIndex("Scope")
.HasDatabaseName("IX_Secret_Scope");

View file

@ -47,6 +47,11 @@ namespace Elsa.Secrets.Persistence.EFCore.Oracle.Migrations.Secrets
.HasMaxLength(200)
.HasColumnType("NVARCHAR2(200)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("NVARCHAR2(200)");
b.Property<string>("Scope")
.HasMaxLength(200)
.HasColumnType("NVARCHAR2(200)");
@ -81,9 +86,9 @@ namespace Elsa.Secrets.Persistence.EFCore.Oracle.Migrations.Secrets
b.HasKey("Id");
b.HasIndex("Name")
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("IX_Secret_Name");
.HasDatabaseName("IX_Secret_NormalizedName");
b.HasIndex("Scope")
.HasDatabaseName("IX_Secret_Scope");

View file

@ -29,6 +29,7 @@ namespace Elsa.Secrets.Persistence.EFCore.Oracle.Migrations.Secrets
{
Id = table.Column<string>(type: "NVARCHAR2(450)", nullable: false),
Name = table.Column<string>(type: "NVARCHAR2(200)", maxLength: 200, nullable: false),
NormalizedName = table.Column<string>(type: "NVARCHAR2(200)", maxLength: 200, nullable: false),
DisplayName = table.Column<string>(type: "NVARCHAR2(200)", maxLength: 200, nullable: false),
Description = table.Column<string>(type: "NVARCHAR2(2000)", nullable: true),
TypeName = table.Column<string>(type: "NVARCHAR2(100)", maxLength: 100, nullable: false),
@ -46,10 +47,10 @@ namespace Elsa.Secrets.Persistence.EFCore.Oracle.Migrations.Secrets
});
migrationBuilder.CreateIndex(
name: "IX_Secret_Name",
name: "IX_Secret_NormalizedName",
schema: _schema.Schema,
table: "Secrets",
column: "Name",
column: "NormalizedName",
unique: true);
migrationBuilder.CreateIndex(

View file

@ -44,6 +44,11 @@ namespace Elsa.Secrets.Persistence.EFCore.Oracle.Migrations.Secrets
.HasMaxLength(200)
.HasColumnType("NVARCHAR2(200)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("NVARCHAR2(200)");
b.Property<string>("Scope")
.HasMaxLength(200)
.HasColumnType("NVARCHAR2(200)");
@ -78,9 +83,9 @@ namespace Elsa.Secrets.Persistence.EFCore.Oracle.Migrations.Secrets
b.HasKey("Id");
b.HasIndex("Name")
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("IX_Secret_Name");
.HasDatabaseName("IX_Secret_NormalizedName");
b.HasIndex("Scope")
.HasDatabaseName("IX_Secret_Scope");

View file

@ -47,6 +47,11 @@ namespace Elsa.Secrets.Persistence.EFCore.PostgreSql.Migrations.Secrets
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Scope")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
@ -81,9 +86,9 @@ namespace Elsa.Secrets.Persistence.EFCore.PostgreSql.Migrations.Secrets
b.HasKey("Id");
b.HasIndex("Name")
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("IX_Secret_Name");
.HasDatabaseName("IX_Secret_NormalizedName");
b.HasIndex("Scope")
.HasDatabaseName("IX_Secret_Scope");

View file

@ -29,6 +29,7 @@ namespace Elsa.Secrets.Persistence.EFCore.PostgreSql.Migrations.Secrets
{
Id = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
NormalizedName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
DisplayName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Description = table.Column<string>(type: "text", nullable: true),
TypeName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
@ -46,10 +47,10 @@ namespace Elsa.Secrets.Persistence.EFCore.PostgreSql.Migrations.Secrets
});
migrationBuilder.CreateIndex(
name: "IX_Secret_Name",
name: "IX_Secret_NormalizedName",
schema: _schema.Schema,
table: "Secrets",
column: "Name",
column: "NormalizedName",
unique: true);
migrationBuilder.CreateIndex(

View file

@ -44,6 +44,11 @@ namespace Elsa.Secrets.Persistence.EFCore.PostgreSql.Migrations.Secrets
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Scope")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
@ -78,9 +83,9 @@ namespace Elsa.Secrets.Persistence.EFCore.PostgreSql.Migrations.Secrets
b.HasKey("Id");
b.HasIndex("Name")
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("IX_Secret_Name");
.HasDatabaseName("IX_Secret_NormalizedName");
b.HasIndex("Scope")
.HasDatabaseName("IX_Secret_Scope");

View file

@ -47,6 +47,11 @@ namespace Elsa.Secrets.Persistence.EFCore.SqlServer.Migrations.Secrets
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("Scope")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
@ -81,9 +86,9 @@ namespace Elsa.Secrets.Persistence.EFCore.SqlServer.Migrations.Secrets
b.HasKey("Id");
b.HasIndex("Name")
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("IX_Secret_Name");
.HasDatabaseName("IX_Secret_NormalizedName");
b.HasIndex("Scope")
.HasDatabaseName("IX_Secret_Scope");

View file

@ -29,6 +29,7 @@ namespace Elsa.Secrets.Persistence.EFCore.SqlServer.Migrations.Secrets
{
Id = table.Column<string>(type: "nvarchar(450)", nullable: false),
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
NormalizedName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
DisplayName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
TypeName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
@ -46,10 +47,10 @@ namespace Elsa.Secrets.Persistence.EFCore.SqlServer.Migrations.Secrets
});
migrationBuilder.CreateIndex(
name: "IX_Secret_Name",
name: "IX_Secret_NormalizedName",
schema: _schema.Schema,
table: "Secrets",
column: "Name",
column: "NormalizedName",
unique: true);
migrationBuilder.CreateIndex(

View file

@ -44,6 +44,11 @@ namespace Elsa.Secrets.Persistence.EFCore.SqlServer.Migrations.Secrets
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("Scope")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
@ -78,9 +83,9 @@ namespace Elsa.Secrets.Persistence.EFCore.SqlServer.Migrations.Secrets
b.HasKey("Id");
b.HasIndex("Name")
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("IX_Secret_Name");
.HasDatabaseName("IX_Secret_NormalizedName");
b.HasIndex("Scope")
.HasDatabaseName("IX_Secret_Scope");

View file

@ -43,6 +43,11 @@ namespace Elsa.Secrets.Persistence.EFCore.Sqlite.Migrations.Secrets
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Scope")
.HasMaxLength(200)
.HasColumnType("TEXT");
@ -77,9 +82,9 @@ namespace Elsa.Secrets.Persistence.EFCore.Sqlite.Migrations.Secrets
b.HasKey("Id");
b.HasIndex("Name")
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("IX_Secret_Name");
.HasDatabaseName("IX_Secret_NormalizedName");
b.HasIndex("Scope")
.HasDatabaseName("IX_Secret_Scope");

View file

@ -20,6 +20,7 @@ namespace Elsa.Secrets.Persistence.EFCore.Sqlite.Migrations.Secrets
{
Id = table.Column<string>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
NormalizedName = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
DisplayName = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
Description = table.Column<string>(type: "TEXT", nullable: true),
TypeName = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
@ -37,10 +38,10 @@ namespace Elsa.Secrets.Persistence.EFCore.Sqlite.Migrations.Secrets
});
migrationBuilder.CreateIndex(
name: "IX_Secret_Name",
name: "IX_Secret_NormalizedName",
schema: "Elsa",
table: "Secrets",
column: "Name",
column: "NormalizedName",
unique: true);
migrationBuilder.CreateIndex(

View file

@ -40,6 +40,11 @@ namespace Elsa.Secrets.Persistence.EFCore.Sqlite.Migrations.Secrets
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Scope")
.HasMaxLength(200)
.HasColumnType("TEXT");
@ -74,9 +79,9 @@ namespace Elsa.Secrets.Persistence.EFCore.Sqlite.Migrations.Secrets
b.HasKey("Id");
b.HasIndex("Name")
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("IX_Secret_Name");
.HasDatabaseName("IX_Secret_NormalizedName");
b.HasIndex("Scope")
.HasDatabaseName("IX_Secret_Scope");

View file

@ -7,8 +7,6 @@ namespace Elsa.Secrets.Persistence.EFCore.Repositories;
public class EFCoreSecretRepository(Store<SecretsElsaDbContext, Secret> store) : ISecretRepository
{
private static readonly SemaphoreSlim Semaphore = new(1, 1);
public async Task<Secret?> GetAsync(string normalizedName, CancellationToken cancellationToken = default)
{
await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
@ -30,83 +28,66 @@ public class EFCoreSecretRepository(Store<SecretsElsaDbContext, Secret> store) :
public async Task AddAsync(Secret secret, CancellationToken cancellationToken = default)
{
await Semaphore.WaitAsync(cancellationToken);
try
{
await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
var normalizedName = NormalizeName(secret.Name);
if (await dbContext.Secrets.AnyAsync(x => x.Name.ToLower() == normalizedName, cancellationToken))
throw new InvalidOperationException($"A secret named '{secret.Name}' already exists.");
await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
var normalizedName = NormalizeName(secret.Name);
if (await ExistsByNormalizedNameAsync(dbContext, normalizedName, cancellationToken))
throw new InvalidOperationException($"A secret named '{secret.Name}' already exists.");
await dbContext.Secrets.AddAsync(secret, cancellationToken);
SecretSerialization.StoreSerializedProperties(dbContext, secret);
await dbContext.SaveChangesAsync(cancellationToken);
}
finally
{
Semaphore.Release();
}
await dbContext.Secrets.AddAsync(secret, cancellationToken);
SetNormalizedName(dbContext, secret);
SecretSerialization.StoreSerializedProperties(dbContext, secret);
await SaveChangesAsync(dbContext, secret.Name, cancellationToken);
}
public async Task<bool> TryAddOrReplaceDeletedAsync(Secret secret, CancellationToken cancellationToken = default)
{
await Semaphore.WaitAsync(cancellationToken);
try
await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
var existingSecret = await FindByNameAsync(dbContext, secret.Name, cancellationToken);
if (existingSecret == null)
{
await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
var existingSecret = await FindByNameAsync(dbContext, secret.Name, cancellationToken);
if (existingSecret == null)
{
await dbContext.Secrets.AddAsync(secret, cancellationToken);
SecretSerialization.StoreSerializedProperties(dbContext, secret);
await dbContext.SaveChangesAsync(cancellationToken);
return true;
}
if (existingSecret.Status != SecretStatus.Deleted)
return false;
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
dbContext.Secrets.Remove(existingSecret);
await dbContext.SaveChangesAsync(cancellationToken);
await dbContext.Secrets.AddAsync(secret, cancellationToken);
SetNormalizedName(dbContext, secret);
SecretSerialization.StoreSerializedProperties(dbContext, secret);
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return true;
}
finally
{
Semaphore.Release();
return await TrySaveChangesAsync(dbContext, secret.Name, cancellationToken);
}
if (existingSecret.Status != SecretStatus.Deleted)
return false;
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
dbContext.Secrets.Remove(existingSecret);
await dbContext.SaveChangesAsync(cancellationToken);
await dbContext.Secrets.AddAsync(secret, cancellationToken);
SetNormalizedName(dbContext, secret);
SecretSerialization.StoreSerializedProperties(dbContext, secret);
if (!await TrySaveChangesAsync(dbContext, secret.Name, cancellationToken))
return false;
await transaction.CommitAsync(cancellationToken);
return true;
}
public async Task SaveAsync(Secret secret, CancellationToken cancellationToken = default)
{
await Semaphore.WaitAsync(cancellationToken);
try
{
await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
var existingSecret = await FindByNameAsync(dbContext, secret.Name, cancellationToken);
await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
var existingSecret = await FindByNameAsync(dbContext, secret.Name, cancellationToken);
if (existingSecret == null)
{
await dbContext.Secrets.AddAsync(secret, cancellationToken);
SecretSerialization.StoreSerializedProperties(dbContext, secret);
}
else
{
Copy(secret, existingSecret);
SecretSerialization.StoreSerializedProperties(dbContext, existingSecret);
}
await dbContext.SaveChangesAsync(cancellationToken);
}
finally
if (existingSecret == null)
{
Semaphore.Release();
await dbContext.Secrets.AddAsync(secret, cancellationToken);
SetNormalizedName(dbContext, secret);
SecretSerialization.StoreSerializedProperties(dbContext, secret);
}
else
{
Copy(secret, existingSecret);
SetNormalizedName(dbContext, existingSecret);
SecretSerialization.StoreSerializedProperties(dbContext, existingSecret);
}
await SaveChangesAsync(dbContext, secret.Name, cancellationToken);
}
private static void Copy(Secret source, Secret target)
@ -127,8 +108,55 @@ public class EFCoreSecretRepository(Store<SecretsElsaDbContext, Secret> store) :
private static Task<Secret?> FindByNameAsync(SecretsElsaDbContext dbContext, string name, CancellationToken cancellationToken)
{
var normalizedName = NormalizeName(name);
return dbContext.Secrets.FirstOrDefaultAsync(x => x.Name.ToLower() == normalizedName, cancellationToken);
return dbContext.Secrets.FirstOrDefaultAsync(x => EF.Property<string>(x, SecretConfiguration.NormalizedNamePropertyName) == normalizedName, cancellationToken);
}
private static string NormalizeName(string name) => name.ToLowerInvariant();
private static Task<bool> ExistsByNormalizedNameAsync(SecretsElsaDbContext dbContext, string normalizedName, CancellationToken cancellationToken)
{
return dbContext.Secrets.AnyAsync(x => EF.Property<string>(x, SecretConfiguration.NormalizedNamePropertyName) == normalizedName, cancellationToken);
}
private async Task SaveChangesAsync(SecretsElsaDbContext dbContext, string name, CancellationToken cancellationToken)
{
try
{
await dbContext.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException e)
{
if (await IsNameConflictAsync(name, cancellationToken))
throw new InvalidOperationException($"A secret named '{name}' already exists.", e);
throw;
}
}
private async Task<bool> TrySaveChangesAsync(SecretsElsaDbContext dbContext, string name, CancellationToken cancellationToken)
{
try
{
await dbContext.SaveChangesAsync(cancellationToken);
return true;
}
catch (DbUpdateException)
{
if (await IsNameConflictAsync(name, cancellationToken))
return false;
throw;
}
}
private async Task<bool> IsNameConflictAsync(string name, CancellationToken cancellationToken)
{
await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
return await ExistsByNormalizedNameAsync(dbContext, NormalizeName(name), cancellationToken);
}
private static void SetNormalizedName(SecretsElsaDbContext dbContext, Secret secret)
{
dbContext.Entry(secret).Property(SecretConfiguration.NormalizedNamePropertyName).CurrentValue = NormalizeName(secret.Name);
}
private static string NormalizeName(string name) => name.Trim().ToLowerInvariant();
}

View file

@ -6,6 +6,8 @@ namespace Elsa.Secrets.Persistence.EFCore;
internal class SecretConfiguration : IEntityTypeConfiguration<Secret>
{
public const string NormalizedNamePropertyName = "NormalizedName";
public void Configure(EntityTypeBuilder<Secret> builder)
{
builder.HasKey(x => x.Id);
@ -15,12 +17,13 @@ internal class SecretConfiguration : IEntityTypeConfiguration<Secret>
builder.Property<string>(SecretSerialization.SerializedTagsPropertyName).HasColumnName("Tags").IsRequired();
builder.Property<string>(SecretSerialization.SerializedVersionsPropertyName).HasColumnName("Versions").IsRequired();
builder.Property(x => x.Name).HasMaxLength(200).IsRequired();
builder.Property<string>(NormalizedNamePropertyName).HasMaxLength(200).IsRequired();
builder.Property(x => x.DisplayName).HasMaxLength(200).IsRequired();
builder.Property(x => x.TypeName).HasMaxLength(100).IsRequired();
builder.Property(x => x.StoreName).HasMaxLength(100).IsRequired();
builder.Property(x => x.Scope).HasMaxLength(200);
builder.Property(x => x.Status).HasConversion<string>().HasMaxLength(32).IsRequired();
builder.HasIndex(x => x.Name).HasDatabaseName($"IX_{nameof(Secret)}_{nameof(Secret.Name)}").IsUnique();
builder.HasIndex(NormalizedNamePropertyName).HasDatabaseName($"IX_{nameof(Secret)}_{NormalizedNamePropertyName}").IsUnique();
builder.HasIndex(x => x.TypeName).HasDatabaseName($"IX_{nameof(Secret)}_{nameof(Secret.TypeName)}");
builder.HasIndex(x => x.StoreName).HasDatabaseName($"IX_{nameof(Secret)}_{nameof(Secret.StoreName)}");
builder.HasIndex(x => x.Scope).HasDatabaseName($"IX_{nameof(Secret)}_{nameof(Secret.Scope)}");

View file

@ -92,11 +92,14 @@ public class EFCoreSecretRepositoryTests : IAsyncLifetime
await repository.AddAsync(new Secret { Name = "SMTP:PASSWORD", DisplayName = "SMTP password" });
var reloaded = await repository.GetAsync("smtp:password");
var whitespaceReloaded = await repository.GetAsync(" SMTP:PASSWORD ");
var activeReplacementResult = await repository.TryAddOrReplaceDeletedAsync(new Secret { Name = "smtp:password", DisplayName = "Replacement password" });
var duplicateException = await Assert.ThrowsAsync<InvalidOperationException>(() => repository.AddAsync(new Secret { Name = "smtp:password", DisplayName = "Duplicate password" }));
Assert.NotNull(reloaded);
Assert.NotNull(whitespaceReloaded);
Assert.Equal("SMTP:PASSWORD", reloaded.Name);
Assert.Equal(reloaded.Id, whitespaceReloaded.Id);
Assert.False(activeReplacementResult);
Assert.Equal("A secret named 'smtp:password' already exists.", duplicateException.Message);
}