fix(secrets): review round 1 — pre-host expander reads the same store the runtime serves, raw-string Replication:Enabled parse, leftover-key warning

- SecretsRegistration: UsesCentralSharedStore predicate shared by AddScadaBridgeSecrets and the
  new AddPreHostExpander, so a Central node's Layer-A ${secret:} expander composes against the
  shared SQL store whenever Secrets:Store:Provider=SqlServer does — closing the gap where a
  central migrated off the retired Grpc topology would boot-time-resolve config from per-node
  SQLite while serving the shared store.
- Program.cs: expander block now calls AddPreHostExpander + resolves the store-agnostic
  ISecretsStoreMigrator seam; stale Grpc-hub/replication comment rewritten.
- DeployedSecretsMirror.cs: stale "gRPC-hub follower half" doc comment rewritten to state the
  now-permanent local-SQLite-only invariant.
- StartupValidator: Secrets:Replication:Enabled read as a raw string (any non-empty, non-"false"
  value rejected) instead of GetValue<bool>, which threw a raw binder crash on "yes"/"1"; added a
  CollectWarnings entry for leftover Secrets:Replication/Secrets:GrpcHub child keys once Enabled
  is false/unset.
- Tests: +15 (AddPreHostExpander/UsesCentralSharedStore wiring, Replication:Enabled edge values,
  leftover-key warnings). Full solution build and full Host.Tests green (593/593).
This commit is contained in:
Joseph Doherty
2026-09-28 20:10:17 -04:00
parent 2bac13170c
commit bc29dc412b
6 changed files with 423 additions and 30 deletions
@@ -784,6 +784,11 @@ public class StartupValidatorTests
[Theory]
[InlineData("true")]
[InlineData("True")]
// Review round 1, Minor #3: these are not valid booleans. GetValue<bool> used to throw a raw
// OptionsValidationException-style binder crash on them instead of the designed message; the
// key is read as a raw string now, so any non-"false" value is "set" and rejected the same way.
[InlineData("yes")]
[InlineData("1")]
public void ReplicationEnabled_IsRejected_OnCentral(string value)
{
var values = ValidCentralConfig();
@@ -825,6 +830,117 @@ public class StartupValidatorTests
Assert.Null(ex);
}
[Fact]
public void ReplicationDisabled_CaseInsensitive_PassesValidation()
{
var values = ValidCentralConfig();
values["Secrets:Replication:Enabled"] = "FALSE";
var config = BuildConfig(values);
var ex = Record.Exception(() => StartupValidator.Validate(config));
Assert.Null(ex);
}
/// <summary>
/// Deliberately stricter than <c>bool</c> parsing (review round 1, Minor #3): only an absent
/// key or exactly (case-insensitively) <c>"false"</c> is safe. <c>"0"</c> is a legal
/// <c>bool.TryParse</c>-adjacent falsy spelling in some conventions but is NOT exempted here —
/// a leftover key this malformed should surface the retirement message, not silently pass.
/// </summary>
[Fact]
public void ReplicationEnabled_Zero_IsRejected()
{
var values = ValidCentralConfig();
values["Secrets:Replication:Enabled"] = "0";
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains(
"Secrets replication (the gRPC hub) is retired in ScadaBridge", ex.Message);
}
/// <summary>
/// Review round 1, Minor #4: a leftover <c>Secrets:Replication</c>/<c>Secrets:GrpcHub</c> key
/// from before Task 11 is not itself a boot failure (Validate already rejects Enabled=true
/// outright), but every OTHER key under either section is now silently ignored — worth a
/// Warning so an operator cleans up QA/prod appsettings.
/// </summary>
[Fact]
public void CollectWarnings_WarnsForLeftoverReplicationModeKey()
{
var values = ValidCentralConfig();
values["Secrets:Store:Provider"] = "SqlServer";
values["Secrets:Replication:Mode"] = "SqlServer";
var config = BuildConfig(values);
var warnings = StartupValidator.CollectWarnings(config);
Assert.Contains(warnings, w =>
w.Contains("Secrets:Replication / Secrets:GrpcHub settings are ignored", StringComparison.Ordinal)
&& w.Contains("the secrets hub is retired; remove them", StringComparison.Ordinal));
}
[Fact]
public void CollectWarnings_WarnsForLeftoverGrpcHubKey()
{
var values = ValidCentralConfig();
values["Secrets:Store:Provider"] = "SqlServer";
values["Secrets:GrpcHub:BearerToken"] = "stale-leftover-token";
var config = BuildConfig(values);
var warnings = StartupValidator.CollectWarnings(config);
Assert.Contains(warnings, w =>
w.Contains("Secrets:Replication / Secrets:GrpcHub settings are ignored", StringComparison.Ordinal));
}
[Fact]
public void CollectWarnings_WarnsForLeftoverKeys_OnSiteToo()
{
// The warning is not central-scoped: a Site config can carry leftover GrpcHub follower
// settings just as easily.
var values = ValidSiteConfig();
values["Secrets:GrpcHub:Endpoint"] = "http://stale-central:8083";
var config = BuildConfig(values);
var warnings = StartupValidator.CollectWarnings(config);
Assert.Contains(warnings, w =>
w.Contains("Secrets:Replication / Secrets:GrpcHub settings are ignored", StringComparison.Ordinal));
}
[Fact]
public void CollectWarnings_SilentForBareReplicationEnabledFalse()
{
// "Enabled: false" alone is the normal, still-valid shape of every existing deployment's
// config (it is what ships in appsettings.json) — this must NOT warn just because the
// Secrets:Replication section exists.
var values = ValidCentralConfig();
values["Secrets:Store:Provider"] = "SqlServer";
values["Secrets:Replication:Enabled"] = "false";
var config = BuildConfig(values);
var warnings = StartupValidator.CollectWarnings(config);
Assert.DoesNotContain(warnings, w =>
w.Contains("Secrets:Replication / Secrets:GrpcHub settings are ignored", StringComparison.Ordinal));
}
[Fact]
public void CollectWarnings_SilentWhenNeitherSectionPresent()
{
var values = ValidCentralConfig();
values["Secrets:Store:Provider"] = "SqlServer";
var config = BuildConfig(values);
var warnings = StartupValidator.CollectWarnings(config);
Assert.DoesNotContain(warnings, w =>
w.Contains("Secrets:Replication / Secrets:GrpcHub settings are ignored", StringComparison.Ordinal));
}
[Fact]
public void CollectWarnings_SilentForSingleSeedCentral()
{