fix(configuration-database): resolve ConfigurationDatabase-002..007 — remove hardcoded sa creds, fail-fast no-arg DI, encrypt secret columns, resilient audit serialization
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
| Last reviewed | 2026-05-16 |
|
||||
| Reviewer | claude-agent |
|
||||
| Commit reviewed | `9c60592` |
|
||||
| Open findings | 10 |
|
||||
| Open findings | 6 |
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -113,7 +113,7 @@ template-aggregate contract the callers depend on.
|
||||
|--|--|
|
||||
| Severity | Medium |
|
||||
| Category | Security |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
| Location | `src/ScadaLink.ConfigurationDatabase/DesignTimeDbContextFactory.cs:21-22` |
|
||||
|
||||
**Description**
|
||||
@@ -136,7 +136,19 @@ and never use `sa`.
|
||||
|
||||
**Resolution**
|
||||
|
||||
_Unresolved._
|
||||
Resolved 2026-05-16 (commit pending). Root cause confirmed against source: the factory
|
||||
fell back to a literal `User Id=sa;Password=YourPassword;...` connection string when no
|
||||
configured value was found. Removed the hardcoded fallback entirely. The factory now
|
||||
resolves the connection string from the Host's appsettings files or, when those are not
|
||||
present, from the `SCADALINK_DESIGNTIME_CONNECTIONSTRING` environment variable, and
|
||||
throws a clear `InvalidOperationException` (naming both the config key and the env var)
|
||||
when neither yields a value. Also hardened `SetBasePath` to be applied only when the
|
||||
`ScadaLink.Host` directory exists, so the factory degrades cleanly instead of throwing
|
||||
`DirectoryNotFoundException` when run from a context without a sibling Host folder.
|
||||
Regression tests added in `DesignTimeDbContextFactoryTests.cs`:
|
||||
`CreateDbContext_NoConnectionStringConfigured_ThrowsClearException`,
|
||||
`CreateDbContext_ConnectionStringFromEnvironmentVariable_IsUsed`, and
|
||||
`DesignTimeDbContextFactory_SourceContainsNoHardcodedSaCredential`.
|
||||
|
||||
### ConfigurationDatabase-003 — No-arg `AddConfigurationDatabase()` silently registers nothing
|
||||
|
||||
@@ -144,7 +156,7 @@ _Unresolved._
|
||||
|--|--|
|
||||
| Severity | Medium |
|
||||
| Category | Error handling & resilience |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
| Location | `src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs:44-49` |
|
||||
|
||||
**Description**
|
||||
@@ -166,7 +178,21 @@ name (e.g. `AddConfigurationDatabaseNoOp()`), and remove the stale "Phase 0" wor
|
||||
|
||||
**Resolution**
|
||||
|
||||
_Unresolved._
|
||||
Resolved 2026-05-16 (commit pending). Root cause confirmed against source: the
|
||||
parameterless `AddConfigurationDatabase()` overload returned `services` unchanged,
|
||||
registering no `DbContext`, repositories, `IAuditService`, or `IInstanceLocator`.
|
||||
Applied the recommendation's first option: the overload is now marked
|
||||
`[Obsolete(..., error: true)]` so any source reference is a compile-time failure, and
|
||||
its body throws `InvalidOperationException` with an actionable message as
|
||||
defence-in-depth (covering reflection-based invocation or suppressed warnings). The
|
||||
stale "Phase 0 stubs / backward compatibility" XML comment was replaced with one
|
||||
explaining the obsoletion. The pre-existing
|
||||
`ServiceRegistrationTests.AddConfigurationDatabase_NoArgs_DoesNotThrow` test in
|
||||
`UnitTest1.cs`, which encoded the old buggy no-op contract, was updated to
|
||||
`AddConfigurationDatabase_NoArgs_FailsFast` to assert the corrected behaviour.
|
||||
New regression tests added in `ServiceCollectionExtensionsTests.cs`:
|
||||
`AddConfigurationDatabase_NoArgOverload_FailsFastWithClearMessage` and
|
||||
`AddConfigurationDatabase_NoArgOverload_IsMarkedObsoleteAsError`.
|
||||
|
||||
### ConfigurationDatabase-004 — Secret-bearing columns stored in plaintext with no protection
|
||||
|
||||
@@ -174,7 +200,7 @@ _Unresolved._
|
||||
|--|--|
|
||||
| Severity | Medium |
|
||||
| Category | Security |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
| Location | `src/ScadaLink.ConfigurationDatabase/Configurations/NotificationConfiguration.cs:56-57`, `src/ScadaLink.ConfigurationDatabase/Configurations/ExternalSystemConfiguration.cs:25-26,75-77` |
|
||||
|
||||
**Description**
|
||||
@@ -199,7 +225,35 @@ doc to state the chosen at-rest protection.
|
||||
|
||||
**Resolution**
|
||||
|
||||
_Unresolved._
|
||||
Resolved 2026-05-16 (commit pending). Root cause confirmed against source:
|
||||
`SmtpConfiguration.Credentials`, `ExternalSystemDefinition.AuthConfiguration`, and
|
||||
`DatabaseConnectionDefinition.ConnectionString` were mapped as ordinary `nvarchar(4000)`
|
||||
columns and persisted verbatim.
|
||||
|
||||
Implemented the recommendation's first option — an in-module EF Core value converter
|
||||
backed by ASP.NET Data Protection, which the module already uses
|
||||
(`IDataProtectionKeyContext`, `AddDataProtection().PersistKeysToDbContext`). Added
|
||||
`EncryptedStringConverter` (purpose-scoped `IDataProtector`; `Protect` on write,
|
||||
`Unprotect` on read; null-safe; surfaces a clear message on a `CryptographicException`).
|
||||
`ScadaLinkDbContext` gained an `(options, IDataProtectionProvider)` constructor and
|
||||
applies the converter to the three secret columns in `OnModelCreating`; the DI
|
||||
registration in `ServiceCollectionExtensions` now constructs the context with the
|
||||
registered provider. The secret columns were widened to `HasMaxLength(8000)` (EF maps
|
||||
this to `nvarchar(max)` on SQL Server) so ciphertext expansion cannot truncate the
|
||||
value; migration `20260517010521_EncryptSecretColumns` carries the column-type change.
|
||||
Regression tests added in `SecretEncryptionTests.cs` verify the raw column value is
|
||||
never the plaintext secret and that EF transparently decrypts on read, for all three
|
||||
columns plus a null round-trip.
|
||||
|
||||
The encryption scheme itself is fully in-module; the only remaining cross-cutting item
|
||||
is a documentation gap — the design doc does not yet state encryption-at-rest for these
|
||||
fields. That doc update is outside this module's editable scope (constraint: edit only
|
||||
`src/ScadaLink.ConfigurationDatabase`, the tests, and this file) and is surfaced here
|
||||
for a follow-up to `docs/requirements/Component-ConfigurationDatabase.md`. The audit
|
||||
secret-leak concern is mitigated separately by CD-007's serializer hardening; whether
|
||||
callers should additionally redact secret-bearing entities before passing them to
|
||||
`IAuditService` is a caller-side concern in other modules and is also surfaced for
|
||||
follow-up. The code fix in this module is complete.
|
||||
|
||||
### ConfigurationDatabase-005 — Audit `Id` type disagrees with the design doc
|
||||
|
||||
@@ -264,7 +318,7 @@ _Unresolved._
|
||||
|--|--|
|
||||
| Severity | Medium |
|
||||
| Category | Error handling & resilience |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
| Location | `src/ScadaLink.ConfigurationDatabase/Services/AuditService.cs:28-30` |
|
||||
|
||||
**Description**
|
||||
@@ -289,7 +343,23 @@ and document that decision against the design doc's transactional-guarantee sect
|
||||
|
||||
**Resolution**
|
||||
|
||||
_Unresolved._
|
||||
Resolved 2026-05-16 (commit pending). Root cause confirmed against source: `LogAsync`
|
||||
called `JsonSerializer.Serialize(afterState)` with default options, so any `afterState`
|
||||
graph containing a reference cycle threw `JsonException` — and because the audit entry
|
||||
commits in the same transaction as the change it records, that exception rolled back
|
||||
the entire business operation.
|
||||
|
||||
Fix applied per the recommendation: `AuditService` now serializes via a static
|
||||
`JsonSerializerOptions` configured with `ReferenceHandler.IgnoreCycles` and
|
||||
`MaxDepth = 32`. The serialization is additionally wrapped in a `SerializeAfterState`
|
||||
helper that catches a residual `JsonException`/`NotSupportedException` and substitutes a
|
||||
small diagnostic placeholder JSON (`AuditSerializationError` + `StateType`) — an explicit
|
||||
decision that an audit-serialization failure must **degrade gracefully** and never roll
|
||||
back the audited operation. The audit entry is always recorded; the design doc's
|
||||
transactional-guarantee section ("if the change succeeds, the audit entry is always
|
||||
recorded") is thereby honoured even for pathological state objects. Regression test
|
||||
added in `AuditServiceTests.cs`:
|
||||
`LogAsync_AfterStateWithReferenceCycle_DoesNotThrow_AndDoesNotRollBackOperation`.
|
||||
|
||||
### ConfigurationDatabase-008 — `GetApprovedKeysForMethodAsync` CSV parsing silently drops malformed ids
|
||||
|
||||
|
||||
@@ -22,8 +22,10 @@ public class ExternalSystemDefinitionConfiguration : IEntityTypeConfiguration<Ex
|
||||
.IsRequired()
|
||||
.HasMaxLength(50);
|
||||
|
||||
// Stored encrypted at rest (EncryptedStringConverter). Ciphertext is larger than
|
||||
// the plaintext, so the column is sized generously to avoid truncation.
|
||||
builder.Property(e => e.AuthConfiguration)
|
||||
.HasMaxLength(4000);
|
||||
.HasMaxLength(8000);
|
||||
|
||||
builder.HasMany<ExternalSystemMethod>()
|
||||
.WithOne()
|
||||
@@ -72,9 +74,11 @@ public class DatabaseConnectionDefinitionConfiguration : IEntityTypeConfiguratio
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
// Stored encrypted at rest (EncryptedStringConverter). Ciphertext is larger than
|
||||
// the plaintext, so the column is sized generously to avoid truncation.
|
||||
builder.Property(d => d.ConnectionString)
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000);
|
||||
.HasMaxLength(8000);
|
||||
|
||||
builder.HasIndex(d => d.Name).IsUnique();
|
||||
}
|
||||
|
||||
@@ -53,8 +53,10 @@ public class SmtpConfigurationConfiguration : IEntityTypeConfiguration<SmtpConfi
|
||||
.IsRequired()
|
||||
.HasMaxLength(50);
|
||||
|
||||
// Stored encrypted at rest (EncryptedStringConverter). Ciphertext is larger than
|
||||
// the plaintext, so the column is sized generously to avoid truncation.
|
||||
builder.Property(s => s.Credentials)
|
||||
.HasMaxLength(4000);
|
||||
.HasMaxLength(8000);
|
||||
|
||||
builder.Property(s => s.TlsMode)
|
||||
.HasMaxLength(50);
|
||||
|
||||
@@ -6,20 +6,50 @@ namespace ScadaLink.ConfigurationDatabase;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating DbContext instances at design time (used by dotnet ef tooling).
|
||||
/// Reads connection string from Host's appsettings.Central.json.
|
||||
/// Resolves the connection string from the Host's appsettings files, or — for environments
|
||||
/// where those files are not present — from the
|
||||
/// <c>SCADALINK_DESIGNTIME_CONNECTIONSTRING</c> environment variable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// There is deliberately no hardcoded fallback connection string. A credential literal in
|
||||
/// source is committed to version control, encourages copy-paste of <c>sa</c> /
|
||||
/// <c>TrustServerCertificate=True</c> into real environments, and can silently point
|
||||
/// <c>dotnet ef</c> tooling at an unintended database. If no connection string can be
|
||||
/// resolved, this factory fails loudly with an actionable message.
|
||||
/// </remarks>
|
||||
public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<ScadaLinkDbContext>
|
||||
{
|
||||
private const string EnvironmentVariableName = "SCADALINK_DESIGNTIME_CONNECTIONSTRING";
|
||||
private const string ConfigurationKey = "ScadaLink:Database:ConfigurationDb";
|
||||
|
||||
public ScadaLinkDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.SetBasePath(Path.Combine(Directory.GetCurrentDirectory(), "..", "ScadaLink.Host"))
|
||||
.AddJsonFile("appsettings.json", optional: true)
|
||||
.AddJsonFile("appsettings.Central.json", optional: true)
|
||||
.Build();
|
||||
var configurationBuilder = new ConfigurationBuilder();
|
||||
|
||||
var connectionString = configuration["ScadaLink:Database:ConfigurationDb"]
|
||||
?? "Server=localhost,1433;Database=ScadaLink_Config;User Id=sa;Password=YourPassword;TrustServerCertificate=True";
|
||||
// The Host's appsettings files are an optional source — only wire them up when the
|
||||
// Host directory actually exists, otherwise SetBasePath throws DirectoryNotFoundException
|
||||
// (e.g. when this factory is exercised from a test runner with no sibling Host folder).
|
||||
var hostDirectory = Path.Combine(Directory.GetCurrentDirectory(), "..", "ScadaLink.Host");
|
||||
if (Directory.Exists(hostDirectory))
|
||||
{
|
||||
configurationBuilder
|
||||
.SetBasePath(hostDirectory)
|
||||
.AddJsonFile("appsettings.json", optional: true)
|
||||
.AddJsonFile("appsettings.Central.json", optional: true);
|
||||
}
|
||||
|
||||
var configuration = configurationBuilder.Build();
|
||||
|
||||
var connectionString = configuration[ConfigurationKey]
|
||||
?? Environment.GetEnvironmentVariable(EnvironmentVariableName);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(connectionString))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"No design-time database connection string was found. Set the configuration " +
|
||||
$"key '{ConfigurationKey}' in the Host's appsettings file, or set the " +
|
||||
$"'{EnvironmentVariableName}' environment variable, before running dotnet ef tooling.");
|
||||
}
|
||||
|
||||
var optionsBuilder = new DbContextOptionsBuilder<ScadaLinkDbContext>();
|
||||
optionsBuilder.UseSqlServer(connectionString);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core value converter that encrypts a string column at rest using ASP.NET
|
||||
/// Data Protection. Plaintext is protected when written to the database and
|
||||
/// transparently unprotected when read back, so secret-bearing columns
|
||||
/// (SMTP credentials, external-system auth config, database connection strings)
|
||||
/// are never persisted verbatim.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The protector is purpose-scoped so ciphertext from one column cannot be
|
||||
/// unprotected as another. Data Protection keys are persisted to the
|
||||
/// configuration database itself (see <see cref="ScadaLinkDbContext"/> implementing
|
||||
/// <c>IDataProtectionKeyContext</c>), so all central nodes share the same key ring
|
||||
/// and can decrypt each other's writes.
|
||||
/// </remarks>
|
||||
public sealed class EncryptedStringConverter : ValueConverter<string?, string?>
|
||||
{
|
||||
/// <summary>The Data Protection purpose string shared by all encrypted configuration columns.</summary>
|
||||
public const string ProtectorPurpose = "ScadaLink.ConfigurationDatabase.EncryptedColumn";
|
||||
|
||||
public EncryptedStringConverter(IDataProtector protector)
|
||||
: base(
|
||||
plaintext => plaintext == null ? null : protector.Protect(plaintext),
|
||||
ciphertext => ciphertext == null ? null : Unprotect(protector, ciphertext))
|
||||
{
|
||||
}
|
||||
|
||||
private static string Unprotect(IDataProtector protector, string ciphertext)
|
||||
{
|
||||
// A row that predates encryption (or test fixtures inserting raw text) is not valid
|
||||
// protected payload. Unprotect throws CryptographicException in that case; surface a
|
||||
// clearer message rather than a bare crypto failure.
|
||||
try
|
||||
{
|
||||
return protector.Unprotect(ciphertext);
|
||||
}
|
||||
catch (System.Security.Cryptography.CryptographicException ex)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Failed to decrypt an encrypted configuration column. The Data Protection key " +
|
||||
"ring may be unavailable, or the stored value was not written by this system.",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
1348
src/ScadaLink.ConfigurationDatabase/Migrations/20260517010521_EncryptSecretColumns.Designer.cs
generated
Normal file
1348
src/ScadaLink.ConfigurationDatabase/Migrations/20260517010521_EncryptSecretColumns.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class EncryptSecretColumns : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Credentials",
|
||||
table: "SmtpConfigurations",
|
||||
type: "nvarchar(max)",
|
||||
maxLength: 8000,
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(4000)",
|
||||
oldMaxLength: 4000,
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "AuthConfiguration",
|
||||
table: "ExternalSystemDefinitions",
|
||||
type: "nvarchar(max)",
|
||||
maxLength: 8000,
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(4000)",
|
||||
oldMaxLength: 4000,
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "ConnectionString",
|
||||
table: "DatabaseConnectionDefinitions",
|
||||
type: "nvarchar(max)",
|
||||
maxLength: 8000,
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(4000)",
|
||||
oldMaxLength: 4000);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Credentials",
|
||||
table: "SmtpConfigurations",
|
||||
type: "nvarchar(4000)",
|
||||
maxLength: 4000,
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(max)",
|
||||
oldMaxLength: 8000,
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "AuthConfiguration",
|
||||
table: "ExternalSystemDefinitions",
|
||||
type: "nvarchar(4000)",
|
||||
maxLength: 4000,
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(max)",
|
||||
oldMaxLength: 8000,
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "ConnectionString",
|
||||
table: "DatabaseConnectionDefinitions",
|
||||
type: "nvarchar(4000)",
|
||||
maxLength: 4000,
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(max)",
|
||||
oldMaxLength: 8000);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -232,8 +232,8 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||
|
||||
b.Property<string>("ConnectionString")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("nvarchar(4000)");
|
||||
.HasMaxLength(8000)
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("MaxRetries")
|
||||
.HasColumnType("int");
|
||||
@@ -263,8 +263,8 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("AuthConfiguration")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("nvarchar(4000)");
|
||||
.HasMaxLength(8000)
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("AuthType")
|
||||
.IsRequired()
|
||||
@@ -632,8 +632,8 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Credentials")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("nvarchar(4000)");
|
||||
.HasMaxLength(8000)
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("FromAddress")
|
||||
.IsRequired()
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
@@ -15,10 +16,24 @@ namespace ScadaLink.ConfigurationDatabase;
|
||||
|
||||
public class ScadaLinkDbContext : DbContext, IDataProtectionKeyContext
|
||||
{
|
||||
private readonly IDataProtectionProvider? _dataProtectionProvider;
|
||||
|
||||
public ScadaLinkDbContext(DbContextOptions<ScadaLinkDbContext> options) : base(options)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a context with an explicit Data Protection provider used to encrypt
|
||||
/// secret-bearing configuration columns at rest. The runtime resolves this overload
|
||||
/// via DI; design-time tooling uses the single-argument overload.
|
||||
/// </summary>
|
||||
public ScadaLinkDbContext(DbContextOptions<ScadaLinkDbContext> options, IDataProtectionProvider dataProtectionProvider)
|
||||
: base(options)
|
||||
{
|
||||
_dataProtectionProvider = dataProtectionProvider
|
||||
?? throw new ArgumentNullException(nameof(dataProtectionProvider));
|
||||
}
|
||||
|
||||
// Templates
|
||||
public DbSet<Template> Templates => Set<Template>();
|
||||
public DbSet<TemplateAttribute> TemplateAttributes => Set<TemplateAttribute>();
|
||||
@@ -73,5 +88,38 @@ public class ScadaLinkDbContext : DbContext, IDataProtectionKeyContext
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ScadaLinkDbContext).Assembly);
|
||||
|
||||
ApplySecretColumnEncryption(modelBuilder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies encryption-at-rest to columns that hold authentication secrets
|
||||
/// (SMTP credentials, external-system auth config, database connection strings)
|
||||
/// so they are never persisted as plaintext.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When no Data Protection provider is supplied (design-time <c>dotnet ef</c> tooling,
|
||||
/// which only emits schema and never reads or writes secret data), an ephemeral provider
|
||||
/// is used. The encrypted-column type is <c>nvarchar</c> either way, so the generated
|
||||
/// schema is identical regardless of which provider is in effect. The runtime path always
|
||||
/// receives the DI-registered provider whose keys are persisted to this database.
|
||||
/// </remarks>
|
||||
private void ApplySecretColumnEncryption(ModelBuilder modelBuilder)
|
||||
{
|
||||
IDataProtectionProvider provider = _dataProtectionProvider ?? new EphemeralDataProtectionProvider();
|
||||
var converter = new EncryptedStringConverter(
|
||||
provider.CreateProtector(EncryptedStringConverter.ProtectorPurpose));
|
||||
|
||||
modelBuilder.Entity<SmtpConfiguration>()
|
||||
.Property(s => s.Credentials)
|
||||
.HasConversion(converter);
|
||||
|
||||
modelBuilder.Entity<ExternalSystemDefinition>()
|
||||
.Property(e => e.AuthConfiguration)
|
||||
.HasConversion(converter);
|
||||
|
||||
modelBuilder.Entity<DatabaseConnectionDefinition>()
|
||||
.Property(d => d.ConnectionString)
|
||||
.HasConversion((Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter)converter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,10 +15,28 @@ public static class ServiceCollectionExtensions
|
||||
/// </summary>
|
||||
public static IServiceCollection AddConfigurationDatabase(this IServiceCollection services, string connectionString)
|
||||
{
|
||||
services.AddDbContext<ScadaLinkDbContext>(options =>
|
||||
// The DbContext is constructed via the (options, IDataProtectionProvider) overload so
|
||||
// secret-bearing configuration columns are encrypted at rest. AddDataProtection below
|
||||
// registers IDataProtectionProvider as a singleton; resolving it here does not recurse
|
||||
// because key-ring loading is lazy (first Protect/Unprotect), not triggered by
|
||||
// CreateProtector during model building.
|
||||
services.AddDbContext<ScadaLinkDbContext>((serviceProvider, options) =>
|
||||
{
|
||||
options.UseSqlServer(connectionString)
|
||||
.ConfigureWarnings(w => w.Ignore(
|
||||
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
|
||||
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning));
|
||||
});
|
||||
|
||||
// AddDbContext registers ScadaLinkDbContext via EF's activator, which only injects
|
||||
// DbContextOptions. Override that registration (last registration wins for resolution)
|
||||
// with a factory that also supplies the IDataProtectionProvider, so the encrypting
|
||||
// value converter for secret columns is always wired up at runtime.
|
||||
services.AddScoped(serviceProvider =>
|
||||
{
|
||||
var options = serviceProvider.GetRequiredService<DbContextOptions<ScadaLinkDbContext>>();
|
||||
var protectionProvider = serviceProvider.GetRequiredService<IDataProtectionProvider>();
|
||||
return new ScadaLinkDbContext(options, protectionProvider);
|
||||
});
|
||||
|
||||
services.AddScoped<ISecurityRepository, SecurityRepository>();
|
||||
services.AddScoped<ICentralUiRepository, CentralUiRepository>();
|
||||
@@ -38,13 +56,27 @@ public static class ServiceCollectionExtensions
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the ScadaLinkDbContext with no connection string (for backward compatibility / Phase 0 stubs).
|
||||
/// This overload is a no-op placeholder; callers should migrate to the overload that accepts a connection string.
|
||||
/// Obsolete parameterless overload. This previously registered nothing, which meant a
|
||||
/// central node wired up with it failed late and opaquely — the first repository
|
||||
/// resolution threw a DI exception far from the actual misconfiguration. Use
|
||||
/// <see cref="AddConfigurationDatabase(IServiceCollection, string)"/> and pass the
|
||||
/// configured connection string.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Always thrown. The connection string is required; there is no valid no-op registration.
|
||||
/// </exception>
|
||||
[Obsolete(
|
||||
"AddConfigurationDatabase() with no connection string registers nothing and is not a " +
|
||||
"valid configuration. Call AddConfigurationDatabase(connectionString) instead.",
|
||||
error: true)]
|
||||
public static IServiceCollection AddConfigurationDatabase(this IServiceCollection services)
|
||||
{
|
||||
// Retained for backward compatibility during migration.
|
||||
// Site nodes do not use the configuration database, so this is intentionally a no-op.
|
||||
return services;
|
||||
// Defence-in-depth: even if a caller suppresses the compile-time obsolete error,
|
||||
// fail fast at wire-up time rather than silently registering nothing and surfacing
|
||||
// an opaque DI resolution failure much later.
|
||||
throw new InvalidOperationException(
|
||||
"AddConfigurationDatabase() requires a connection string. Call " +
|
||||
"AddConfigurationDatabase(connectionString) with the configured " +
|
||||
"'ScadaLink:Database:ConfigurationDb' value.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,19 @@ public class AuditService : IAuditService
|
||||
{
|
||||
private readonly ScadaLinkDbContext _context;
|
||||
|
||||
/// <summary>
|
||||
/// Serializer options for audit <c>afterState</c> payloads. Audit writes commit in the
|
||||
/// same transaction as the change they record, so a serialization exception here would
|
||||
/// roll back the entire business operation. Reference cycles (common when an EF entity
|
||||
/// with loaded navigations is passed in) are ignored rather than thrown, and depth is
|
||||
/// bounded so a pathological graph cannot produce an unbounded payload.
|
||||
/// </summary>
|
||||
private static readonly JsonSerializerOptions AuditSerializerOptions = new()
|
||||
{
|
||||
ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles,
|
||||
MaxDepth = 32
|
||||
};
|
||||
|
||||
public AuditService(ScadaLinkDbContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
@@ -26,7 +39,7 @@ public class AuditService : IAuditService
|
||||
{
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
AfterStateJson = afterState != null
|
||||
? JsonSerializer.Serialize(afterState)
|
||||
? SerializeAfterState(afterState)
|
||||
: null
|
||||
};
|
||||
|
||||
@@ -34,4 +47,27 @@ public class AuditService : IAuditService
|
||||
// to ensure atomicity with the entity change.
|
||||
await _context.AuditLogEntries.AddAsync(entry, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the caller-supplied after-state, tolerating arbitrary object shapes.
|
||||
/// Reference cycles are ignored via <see cref="AuditSerializerOptions"/>. If serialization
|
||||
/// still fails (e.g. <c>MaxDepth</c> exceeded), the audit entry is preserved with a
|
||||
/// diagnostic placeholder rather than throwing — a serialization failure must never
|
||||
/// roll back the business operation the audit entry is recording.
|
||||
/// </summary>
|
||||
private static string SerializeAfterState(object afterState)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Serialize(afterState, AuditSerializerOptions);
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or NotSupportedException)
|
||||
{
|
||||
return JsonSerializer.Serialize(new
|
||||
{
|
||||
AuditSerializationError = ex.Message,
|
||||
StateType = afterState.GetType().FullName
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,4 +124,35 @@ public class AuditServiceTests : IDisposable
|
||||
Assert.DoesNotContain(methods, m => m.Name.Contains("Update", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(methods, m => m.Name.Contains("Delete", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
// Self-referential POCO used to reproduce a reference cycle in afterState.
|
||||
private sealed class CyclicNode
|
||||
{
|
||||
public string Name { get; set; } = "node";
|
||||
public CyclicNode? Self { get; set; }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LogAsync_AfterStateWithReferenceCycle_DoesNotThrow_AndDoesNotRollBackOperation()
|
||||
{
|
||||
// Regression guard for ConfigurationDatabase-007: serializing an afterState
|
||||
// object that contains a reference cycle must not throw a JsonException —
|
||||
// that would roll back the entire business operation it is auditing.
|
||||
var node = new CyclicNode();
|
||||
node.Self = node; // reference cycle
|
||||
|
||||
var template = new Template("CyclicAuditTemplate");
|
||||
_context.Templates.Add(template);
|
||||
|
||||
// Must not throw.
|
||||
await _auditService.LogAsync("admin", "Create", "Template", "1", "CyclicAuditTemplate", node);
|
||||
|
||||
// The audited business operation must still commit successfully.
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var audit = await _context.AuditLogEntries.SingleAsync();
|
||||
Assert.NotNull(audit.AfterStateJson);
|
||||
Assert.Contains("node", audit.AfterStateJson);
|
||||
Assert.Single(await _context.Templates.Where(t => t.Name == "CyclicAuditTemplate").ToListAsync());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using ScadaLink.ConfigurationDatabase;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Tests;
|
||||
|
||||
public class DesignTimeDbContextFactoryTests : IDisposable
|
||||
{
|
||||
private const string EnvVar = "SCADALINK_DESIGNTIME_CONNECTIONSTRING";
|
||||
private readonly string? _originalEnv;
|
||||
|
||||
public DesignTimeDbContextFactoryTests()
|
||||
{
|
||||
_originalEnv = Environment.GetEnvironmentVariable(EnvVar);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Environment.SetEnvironmentVariable(EnvVar, _originalEnv);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateDbContext_NoConnectionStringConfigured_ThrowsClearException()
|
||||
{
|
||||
// Regression guard for ConfigurationDatabase-002: the factory must not fall back
|
||||
// to a hardcoded `sa`/password literal. With nothing configured it must fail loudly
|
||||
// with an actionable message instead of silently pointing tooling at a guessed DB.
|
||||
Environment.SetEnvironmentVariable(EnvVar, null);
|
||||
var factory = new DesignTimeDbContextFactory();
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => factory.CreateDbContext(Array.Empty<string>()));
|
||||
|
||||
Assert.Contains("connection string", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
// The message must not leak / suggest a hardcoded `sa` credential.
|
||||
Assert.DoesNotContain("sa", ex.Message.Split(' '), StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateDbContext_ConnectionStringFromEnvironmentVariable_IsUsed()
|
||||
{
|
||||
// The design-time connection string may be supplied via an environment variable
|
||||
// rather than a source literal.
|
||||
Environment.SetEnvironmentVariable(EnvVar,
|
||||
"Server=localhost,1433;Database=ScadaLink_Config;Trusted_Connection=True;TrustServerCertificate=True");
|
||||
var factory = new DesignTimeDbContextFactory();
|
||||
|
||||
using var context = factory.CreateDbContext(Array.Empty<string>());
|
||||
|
||||
Assert.NotNull(context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DesignTimeDbContextFactory_SourceContainsNoHardcodedSaCredential()
|
||||
{
|
||||
// Belt-and-braces: assert no `sa`/password literal exists in the compiled type's
|
||||
// behaviour by confirming the no-config path throws rather than connecting.
|
||||
Environment.SetEnvironmentVariable(EnvVar, null);
|
||||
var factory = new DesignTimeDbContextFactory();
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => factory.CreateDbContext(Array.Empty<string>()));
|
||||
Assert.DoesNotContain("YourPassword", ex.Message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using ScadaLink.Commons.Entities.ExternalSystems;
|
||||
using ScadaLink.Commons.Entities.Notifications;
|
||||
using ScadaLink.ConfigurationDatabase;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression guard for ConfigurationDatabase-004: secret-bearing columns
|
||||
/// (SMTP credentials, external-system auth config, database connection strings)
|
||||
/// must be encrypted at rest, not persisted verbatim.
|
||||
/// </summary>
|
||||
public class SecretEncryptionTests : IDisposable
|
||||
{
|
||||
private readonly ScadaLinkDbContext _context;
|
||||
private readonly IDataProtectionProvider _protectionProvider;
|
||||
|
||||
public SecretEncryptionTests()
|
||||
{
|
||||
_protectionProvider = new EphemeralDataProtectionProvider();
|
||||
|
||||
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
|
||||
.UseSqlite("DataSource=:memory:")
|
||||
.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning))
|
||||
.Options;
|
||||
|
||||
_context = new ScadaLinkDbContext(options, _protectionProvider);
|
||||
_context.Database.OpenConnection();
|
||||
_context.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DatabaseConnectionDefinition_ConnectionString_StoredEncrypted_RoundTrips()
|
||||
{
|
||||
const string secret = "Server=db;Database=X;User Id=svc;Password=SuperSecret123!";
|
||||
var def = new DatabaseConnectionDefinition("PrimaryDb", secret);
|
||||
_context.DatabaseConnectionDefinitions.Add(def);
|
||||
await _context.SaveChangesAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
// Raw column value must not be the plaintext secret.
|
||||
var raw = await ReadRawColumnAsync("DatabaseConnectionDefinitions", "ConnectionString", def.Id);
|
||||
Assert.NotNull(raw);
|
||||
Assert.NotEqual(secret, raw);
|
||||
Assert.DoesNotContain("SuperSecret123!", raw);
|
||||
|
||||
// Reading back through EF must transparently decrypt.
|
||||
var loaded = await _context.DatabaseConnectionDefinitions.SingleAsync(d => d.Id == def.Id);
|
||||
Assert.Equal(secret, loaded.ConnectionString);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SmtpConfiguration_Credentials_StoredEncrypted_RoundTrips()
|
||||
{
|
||||
const string secret = "client_secret=oauth2-abc-very-secret";
|
||||
var smtp = new SmtpConfiguration("smtp.example.com", "OAuth2", "noreply@example.com")
|
||||
{
|
||||
Credentials = secret
|
||||
};
|
||||
_context.SmtpConfigurations.Add(smtp);
|
||||
await _context.SaveChangesAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
var raw = await ReadRawColumnAsync("SmtpConfigurations", "Credentials", smtp.Id);
|
||||
Assert.NotNull(raw);
|
||||
Assert.NotEqual(secret, raw);
|
||||
Assert.DoesNotContain("oauth2-abc-very-secret", raw);
|
||||
|
||||
var loaded = await _context.SmtpConfigurations.SingleAsync(s => s.Id == smtp.Id);
|
||||
Assert.Equal(secret, loaded.Credentials);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExternalSystemDefinition_AuthConfiguration_StoredEncrypted_RoundTrips()
|
||||
{
|
||||
const string secret = "{\"apiKey\":\"live-key-do-not-leak\"}";
|
||||
var ext = new ExternalSystemDefinition("Erp", "https://erp.example.com", "ApiKey")
|
||||
{
|
||||
AuthConfiguration = secret
|
||||
};
|
||||
_context.ExternalSystemDefinitions.Add(ext);
|
||||
await _context.SaveChangesAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
var raw = await ReadRawColumnAsync("ExternalSystemDefinitions", "AuthConfiguration", ext.Id);
|
||||
Assert.NotNull(raw);
|
||||
Assert.NotEqual(secret, raw);
|
||||
Assert.DoesNotContain("live-key-do-not-leak", raw);
|
||||
|
||||
var loaded = await _context.ExternalSystemDefinitions.SingleAsync(e => e.Id == ext.Id);
|
||||
Assert.Equal(secret, loaded.AuthConfiguration);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SmtpConfiguration_NullCredentials_RoundTripsAsNull()
|
||||
{
|
||||
var smtp = new SmtpConfiguration("smtp.example.com", "None", "noreply@example.com")
|
||||
{
|
||||
Credentials = null
|
||||
};
|
||||
_context.SmtpConfigurations.Add(smtp);
|
||||
await _context.SaveChangesAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
var loaded = await _context.SmtpConfigurations.SingleAsync(s => s.Id == smtp.Id);
|
||||
Assert.Null(loaded.Credentials);
|
||||
}
|
||||
|
||||
private async Task<string?> ReadRawColumnAsync(string table, string column, int id)
|
||||
{
|
||||
var connection = _context.Database.GetDbConnection();
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = $"SELECT \"{column}\" FROM \"{table}\" WHERE \"Id\" = $id";
|
||||
var p = cmd.CreateParameter();
|
||||
p.ParameterName = "$id";
|
||||
p.Value = id;
|
||||
cmd.Parameters.Add(p);
|
||||
var result = await cmd.ExecuteScalarAsync();
|
||||
return result == null || result == DBNull.Value ? null : (string)result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.ConfigurationDatabase;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Tests;
|
||||
|
||||
public class ServiceCollectionExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddConfigurationDatabase_WithConnectionString_RegistersRepositoriesAndServices()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddConfigurationDatabase("DataSource=:memory:");
|
||||
|
||||
Assert.Contains(services, d => d.ServiceType == typeof(ITemplateEngineRepository));
|
||||
Assert.Contains(services, d => d.ServiceType == typeof(IAuditService));
|
||||
Assert.Contains(services, d => d.ServiceType == typeof(IInstanceLocator));
|
||||
}
|
||||
|
||||
// The no-arg overload is [Obsolete(error: true)], so it cannot be referenced directly
|
||||
// from source — that is the compile-time guard. Invoke it via reflection to verify the
|
||||
// runtime defence-in-depth behaviour.
|
||||
private static MethodInfo NoArgOverload =>
|
||||
typeof(ServiceCollectionExtensions).GetMethod(
|
||||
nameof(ServiceCollectionExtensions.AddConfigurationDatabase),
|
||||
BindingFlags.Public | BindingFlags.Static,
|
||||
binder: null,
|
||||
types: new[] { typeof(IServiceCollection) },
|
||||
modifiers: null)!;
|
||||
|
||||
[Fact]
|
||||
public void AddConfigurationDatabase_NoArgOverload_FailsFastWithClearMessage()
|
||||
{
|
||||
// Regression guard for ConfigurationDatabase-003: the parameterless overload must not
|
||||
// silently register nothing. Misuse must surface immediately at wire-up time with an
|
||||
// actionable message — not later as an opaque DI resolution failure.
|
||||
var services = new ServiceCollection();
|
||||
|
||||
var invocation = Assert.Throws<TargetInvocationException>(
|
||||
() => NoArgOverload.Invoke(null, new object[] { services }));
|
||||
|
||||
var ex = Assert.IsType<InvalidOperationException>(invocation.InnerException);
|
||||
Assert.Contains("connection string", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddConfigurationDatabase_NoArgOverload_IsMarkedObsoleteAsError()
|
||||
{
|
||||
// The no-op overload must be flagged so misuse is caught at compile time.
|
||||
var obsolete = NoArgOverload.GetCustomAttribute<ObsoleteAttribute>();
|
||||
Assert.NotNull(obsolete);
|
||||
Assert.True(obsolete!.IsError);
|
||||
}
|
||||
}
|
||||
@@ -372,15 +372,24 @@ public class ServiceRegistrationTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddConfigurationDatabase_NoArgs_DoesNotThrow()
|
||||
public void AddConfigurationDatabase_NoArgs_FailsFast()
|
||||
{
|
||||
// ConfigurationDatabase-003: the no-arg overload previously silently registered
|
||||
// nothing, which deferred a misconfiguration into an opaque DI failure later.
|
||||
// It is now [Obsolete(error: true)] (compile-time guard) and throws at runtime.
|
||||
// Invoked via reflection because the obsolete-error overload cannot be called
|
||||
// directly from source.
|
||||
var method = typeof(ServiceCollectionExtensions).GetMethod(
|
||||
nameof(ServiceCollectionExtensions.AddConfigurationDatabase),
|
||||
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static,
|
||||
binder: null,
|
||||
types: new[] { typeof(IServiceCollection) },
|
||||
modifiers: null)!;
|
||||
var services = new ServiceCollection();
|
||||
services.AddConfigurationDatabase();
|
||||
|
||||
// Should not register DbContext (no-op for backward compatibility)
|
||||
var provider = services.BuildServiceProvider();
|
||||
var context = provider.GetService<ScadaLinkDbContext>();
|
||||
Assert.Null(context);
|
||||
var invocation = Assert.Throws<System.Reflection.TargetInvocationException>(
|
||||
() => method.Invoke(null, new object[] { services }));
|
||||
Assert.IsType<InvalidOperationException>(invocation.InnerException);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user