fix(configuration-database): resolve ConfigurationDatabase-013,014 — fail-fast on missing key ring, single converter local; ConfigurationDatabase-012 left open (cross-module design decision)

This commit is contained in:
Joseph Doherty
2026-05-17 03:18:24 -04:00
parent a768135237
commit 3d3f43229f
5 changed files with 326 additions and 16 deletions

View File

@@ -0,0 +1,85 @@
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using ScadaLink.Commons.Entities.ExternalSystems;
using ScadaLink.ConfigurationDatabase;
namespace ScadaLink.ConfigurationDatabase.Tests;
/// <summary>
/// Regression guard for ConfigurationDatabase-013: a <see cref="ScadaLinkDbContext"/>
/// constructed without an explicit Data Protection provider (the single-argument
/// constructor) must NOT silently encrypt secret columns with a throwaway ephemeral
/// key — that would persist ciphertext that becomes permanently undecryptable on the
/// next process restart, with no error. Writing a secret column on such a context
/// must fail fast with a clear <see cref="InvalidOperationException"/> instead.
/// </summary>
public class EphemeralEncryptionFallbackTests
{
private static DbContextOptions<ScadaLinkDbContext> SqliteOptions() =>
new DbContextOptionsBuilder<ScadaLinkDbContext>()
.UseSqlite("DataSource=:memory:")
.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning))
.Options;
[Fact]
public async Task SingleArgConstructor_WritingSecretColumn_FailsFast_DoesNotPersistThrowawayCiphertext()
{
// Single-argument constructor: no Data Protection provider supplied (the
// design-time / schema-only path). Schema creation must still succeed.
using var context = new ScadaLinkDbContext(SqliteOptions());
context.Database.OpenConnection();
context.Database.EnsureCreated();
// AuthConfiguration is an encrypted secret column. Persisting it without a real
// key ring would produce undecryptable ciphertext; it must throw instead.
var ext = new ExternalSystemDefinition("Erp", "https://erp.example.com", "ApiKey")
{
AuthConfiguration = "{\"apiKey\":\"live-secret\"}"
};
context.ExternalSystemDefinitions.Add(ext);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => context.SaveChangesAsync());
Assert.Contains("Data Protection", ex.Message);
}
[Fact]
public async Task SingleArgConstructor_WritingNonSecretColumn_Succeeds()
{
// The schema-only / no-provider context must remain fully usable for entities
// that have no encrypted secret columns — only secret writes are gated.
using var context = new ScadaLinkDbContext(SqliteOptions());
context.Database.OpenConnection();
context.Database.EnsureCreated();
var ext = new ExternalSystemDefinition("Erp", "https://erp.example.com", "None");
context.ExternalSystemDefinitions.Add(ext);
await context.SaveChangesAsync();
Assert.True(ext.Id > 0);
}
[Fact]
public async Task ProviderConstructor_WritingSecretColumn_StillSucceeds()
{
// Sanity check: the gating must not regress the supported runtime path where a
// real Data Protection provider is supplied.
using var context = new ScadaLinkDbContext(SqliteOptions(), new EphemeralDataProtectionProvider());
context.Database.OpenConnection();
context.Database.EnsureCreated();
var ext = new ExternalSystemDefinition("Erp", "https://erp.example.com", "ApiKey")
{
AuthConfiguration = "{\"apiKey\":\"live-secret\"}"
};
context.ExternalSystemDefinitions.Add(ext);
await context.SaveChangesAsync();
context.ChangeTracker.Clear();
var loaded = await context.ExternalSystemDefinitions.SingleAsync(e => e.Id == ext.Id);
Assert.Equal("{\"apiKey\":\"live-secret\"}", loaded.AuthConfiguration);
}
}

View File

@@ -1,4 +1,6 @@
using Microsoft.EntityFrameworkCore;
using ScadaLink.Commons.Entities.ExternalSystems;
using ScadaLink.Commons.Entities.Notifications;
using ScadaLink.Commons.Entities.Sites;
using ScadaLink.Commons.Entities.Templates;
using ScadaLink.ConfigurationDatabase;
@@ -48,6 +50,26 @@ public class SchemaConfigurationTests : IDisposable
Assert.Equal(siblingMaxLength, entity.FindProperty(nameof(Site.GrpcNodeAAddress))!.GetMaxLength());
Assert.Equal(siblingMaxLength, entity.FindProperty(nameof(Site.GrpcNodeBAddress))!.GetMaxLength());
}
// ConfigurationDatabase-014: the encrypting value converter must be applied
// uniformly to all three secret-bearing columns, including the non-nullable
// DatabaseConnectionDefinition.ConnectionString. A regression here (e.g. the
// converter dropped from one HasConversion call) would silently store a secret
// in plaintext.
[Theory]
[InlineData(typeof(SmtpConfiguration), nameof(SmtpConfiguration.Credentials))]
[InlineData(typeof(ExternalSystemDefinition), nameof(ExternalSystemDefinition.AuthConfiguration))]
[InlineData(typeof(DatabaseConnectionDefinition), nameof(DatabaseConnectionDefinition.ConnectionString))]
public void SecretColumns_AllHaveEncryptedStringConverterApplied(Type entityType, string propertyName)
{
var converter = _context.Model
.FindEntityType(entityType)!
.FindProperty(propertyName)!
.GetValueConverter();
Assert.IsType<EncryptedStringConverter>(converter);
}
}
public class SplitQueryBehaviourTests : IDisposable

View File

@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@@ -10,10 +11,16 @@ namespace ScadaLink.ConfigurationDatabase.Tests;
/// Test DbContext that adapts SQL Server-specific features for SQLite:
/// - Maps DateTimeOffset to sortable ISO 8601 strings (SQLite has no native DateTimeOffset ORDER BY)
/// - Replaces SQL Server RowVersion with a nullable byte[] column (SQLite can't auto-generate rowversion)
///
/// Constructed with an explicit ephemeral Data Protection provider so secret-bearing
/// columns are write-capable in tests. The schema-only no-provider constructor would
/// throw on a secret-column write (ConfigurationDatabase-013); passing a provider here
/// makes the test fixture's intent explicit at the call site.
/// </summary>
public class SqliteTestDbContext : ScadaLinkDbContext
{
public SqliteTestDbContext(DbContextOptions<ScadaLinkDbContext> options) : base(options)
public SqliteTestDbContext(DbContextOptions<ScadaLinkDbContext> options)
: base(options, new EphemeralDataProtectionProvider())
{
}