feat(secrets): central's Grpc-mode store is the SHARED SQL-Server store (scadaproj#4)
In Secrets:Replication:Mode=Grpc a central node's ISecretStore is now the
shared SQL-Server store (AddZbSecretsSqlServerStore) instead of a per-node
local SQLite store. Both central hubs read and write ONE copy of every row,
so they serve identical manifests by construction — the 2026-08-07 live gate
observed central-b answering an authenticated GetManifest with an EMPTY
manifest while central-a held every secret, which would turn site-side hub
failover into a silent convergence stop.
- SecretsRegistration: two fail-closed pre-checks before any registration on
the central+Grpc path — a blank Secrets:SqlServer:ConnectionString throws
naming the key (an independent store per central node is the recorded
defect), and a value containing ${secret: throws naming the bootstrap
circularity (the expander needs this store to resolve references). Site
registrations are byte-identical to before; SqlServer mode and
replication-off are untouched.
- Program.cs Layer-A expander follows the store swap: central+Grpc with a
non-blank connection string migrates and resolves pre-host ${secret:}
references through the shared SQL store, so expanded values can never
diverge from what the running node serves. Every other case keeps the
SQLite path unchanged; blank-connstr central deliberately falls through so
the clear AddScadaBridgeSecrets message is the one that fails the boot.
- appsettings.json: Secrets:SqlServer _comment now documents the Grpc-mode
central requirement (literal/env value only, sites leave it empty).
- SecretsReplicationWiringTests: +5 pins (shared store resolves, blank and
${secret:} connstrings fail naming the key, sites-have-no-SqlServer-types
descriptor sweep), central fixtures carry the now-required connstr.
Full suite green (7,474 passed across 30 projects, 0 warnings); the two
failures are pre-existing and unrelated: the Playwright live-rig suite fails
identically on unmodified main (cluster not running), and
GrpcCentralTransportTests.DeadlineExceeded_IsNotRetriedOnThePeer is a timing
flake that passes 3/3 in isolation and 470/470 on the first run of this code.
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
This commit is contained in:
@@ -6,6 +6,7 @@ using Microsoft.Extensions.Hosting;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Replication;
|
||||
using ZB.MOM.WW.Secrets.Replicator.Grpc;
|
||||
using ZB.MOM.WW.Secrets.Replicator.SqlServer;
|
||||
using ZB.MOM.WW.Secrets.Sqlite;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
@@ -77,6 +78,16 @@ public class SecretsReplicationWiringTests
|
||||
&& descriptor.ImplementationType?.Assembly.GetName().Name
|
||||
== "ZB.MOM.WW.Secrets.Replicator.SqlServer";
|
||||
|
||||
/// <summary>
|
||||
/// Any descriptor contributed by the SQL-Server replicator package, whatever its shape. The
|
||||
/// package's factory-lambda registrations (its store, migrator, connection factory) carry the
|
||||
/// concrete type as the SERVICE type, so checking both sides catches every registration form.
|
||||
/// </summary>
|
||||
private static bool IsFromSqlServerReplicatorPackage(ServiceDescriptor descriptor) =>
|
||||
descriptor.ServiceType.Assembly.GetName().Name == "ZB.MOM.WW.Secrets.Replicator.SqlServer"
|
||||
|| descriptor.ImplementationType?.Assembly.GetName().Name
|
||||
== "ZB.MOM.WW.Secrets.Replicator.SqlServer";
|
||||
|
||||
/// <summary>
|
||||
/// The gRPC hub package's one hosted service — the follower sweep. Asserted by assembly and
|
||||
/// type NAME because <c>GrpcSecretSyncService</c> is internal to the package. The hub SERVER
|
||||
@@ -87,11 +98,17 @@ public class SecretsReplicationWiringTests
|
||||
service.GetType().Assembly.GetName().Name == "ZB.MOM.WW.Secrets.Replicator.Grpc"
|
||||
&& service.GetType().Name == "GrpcSecretSyncService";
|
||||
|
||||
/// <summary>
|
||||
/// The complete central shape for gRPC mode, connection string included: central's store in
|
||||
/// this mode is the SHARED SQL-Server store (scadaproj#4), so a central config without a
|
||||
/// connection string is not a valid variant — it is a registration failure, pinned separately.
|
||||
/// </summary>
|
||||
private static (string Key, string Value)[] GrpcCentralConfig() =>
|
||||
[
|
||||
("Secrets:Replication:Enabled", "true"),
|
||||
("Secrets:Replication:Mode", "Grpc"),
|
||||
("Secrets:GrpcHub:BearerToken", DummyBearerToken),
|
||||
("Secrets:SqlServer:ConnectionString", DummyConnectionString),
|
||||
];
|
||||
|
||||
private static (string Key, string Value)[] GrpcSiteConfig() =>
|
||||
@@ -264,8 +281,10 @@ public class SecretsReplicationWiringTests
|
||||
Assert.NotNull(provider.GetService<SecretsHubAuthInterceptor>());
|
||||
Assert.True(SecretsRegistration.UsesGrpcHub(config));
|
||||
|
||||
// The local store is still the plain one — pull-only means nothing decorates it.
|
||||
Assert.IsType<SqliteSecretStore>(provider.GetRequiredService<ISecretStore>());
|
||||
// Central's store is the SHARED SQL-Server store — one copy of every row, so both
|
||||
// central hubs serve identical manifests by construction (scadaproj#4). Undecorated:
|
||||
// pull-only means nothing publishes, so there is still nothing to wrap it in.
|
||||
Assert.IsType<SqlServerSecretStore>(provider.GetRequiredService<ISecretStore>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -313,9 +332,12 @@ public class SecretsReplicationWiringTests
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
// Connection string present so the missing bearer token is the ONE defect in this config —
|
||||
// the connection-string pre-check runs first and would otherwise mask it.
|
||||
IConfiguration config = BuildConfig(
|
||||
("Secrets:Replication:Enabled", "true"),
|
||||
("Secrets:Replication:Mode", "Grpc"));
|
||||
("Secrets:Replication:Mode", "Grpc"),
|
||||
("Secrets:SqlServer:ConnectionString", DummyConnectionString));
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(
|
||||
() => services.AddScadaBridgeSecrets(config, SecretsNodeRole.Central));
|
||||
@@ -324,6 +346,91 @@ public class SecretsReplicationWiringTests
|
||||
Assert.Contains("Secrets:GrpcHub:BearerToken", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Central + gRPC + no connection string is the scadaproj#4 defect asked for by name: each
|
||||
/// central node on an independent local store, one of them answering authenticated followers
|
||||
/// with an EMPTY manifest. It must be a boot failure naming the key, never a quiet fallback.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void GrpcMode_Central_WithBlankConnectionString_FailsAtRegistration(string? connectionString)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
(string, string)[] overrides = connectionString is null
|
||||
?
|
||||
[
|
||||
("Secrets:Replication:Enabled", "true"),
|
||||
("Secrets:Replication:Mode", "Grpc"),
|
||||
("Secrets:GrpcHub:BearerToken", DummyBearerToken),
|
||||
]
|
||||
:
|
||||
[
|
||||
("Secrets:Replication:Enabled", "true"),
|
||||
("Secrets:Replication:Mode", "Grpc"),
|
||||
("Secrets:GrpcHub:BearerToken", DummyBearerToken),
|
||||
("Secrets:SqlServer:ConnectionString", connectionString),
|
||||
];
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(
|
||||
() => services.AddScadaBridgeSecrets(BuildConfig(overrides), SecretsNodeRole.Central));
|
||||
|
||||
Assert.Contains(
|
||||
SecretsRegistration.HubConnectionStringKey, ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The store's own connection string can never be a <c>${secret:}</c> reference — the pre-host
|
||||
/// expander needs the store this string points at in order to resolve it (bootstrap
|
||||
/// circularity, the same rule as the hub bearer token). Rejected by name rather than left to
|
||||
/// fail as a malformed connection string on first use.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GrpcMode_Central_WithSecretReferenceConnectionString_FailsAtRegistration()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
IConfiguration config = BuildConfig(
|
||||
("Secrets:Replication:Enabled", "true"),
|
||||
("Secrets:Replication:Mode", "Grpc"),
|
||||
("Secrets:GrpcHub:BearerToken", DummyBearerToken),
|
||||
("Secrets:SqlServer:ConnectionString", "${secret:hub-connection-string}"));
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(
|
||||
() => services.AddScadaBridgeSecrets(config, SecretsNodeRole.Central));
|
||||
|
||||
Assert.Contains(
|
||||
SecretsRegistration.HubConnectionStringKey, ex.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("${secret:", ex.Message, StringComparison.Ordinal);
|
||||
// The reference NAME is config, not secret material — but the message must still not echo
|
||||
// the configured value, because a mistyped value here could be a pasted real credential.
|
||||
Assert.DoesNotContain("hub-connection-string", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The sites-don't-talk-to-SQL pin: a site node's container must contain NOTHING from the
|
||||
/// SQL-Server replicator package — not just resolve a SQLite store, but hold no descriptor
|
||||
/// that could ever open a connection to central's database.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GrpcMode_Site_HasNoSqlServerPackageTypesInTheContainer()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddScadaBridgeSecrets(BuildConfig(GrpcSiteConfig()), SecretsNodeRole.Site);
|
||||
|
||||
Assert.DoesNotContain(services, IsFromSqlServerReplicatorPackage);
|
||||
|
||||
using ServiceProvider provider = services.BuildServiceProvider(validateScopes: true);
|
||||
|
||||
Assert.IsType<SqliteSecretStore>(provider.GetRequiredService<ISecretStore>());
|
||||
Assert.Null(provider.GetService<SqlServerSecretStore>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GrpcMode_Site_WithoutEndpoint_FailsAtRegistration()
|
||||
{
|
||||
@@ -468,13 +575,19 @@ public class SecretsHubEndpointMappingTests
|
||||
|
||||
private static bool HasHubEndpoint(WebApplication app) => HubRoutes(app).Count > 0;
|
||||
|
||||
// Syntactically valid, deliberately unreachable — central + gRPC requires the shared
|
||||
// SQL-Server store's connection string at registration; nothing here ever connects.
|
||||
private const string DummyConnectionString =
|
||||
"Server=unused;Database=x;Integrated Security=true;";
|
||||
|
||||
[Fact]
|
||||
public void GrpcMode_Central_MapsTheHubEndpoint()
|
||||
{
|
||||
using WebApplication app = BuildCentralApp(
|
||||
("Secrets:Replication:Enabled", "true"),
|
||||
("Secrets:Replication:Mode", "Grpc"),
|
||||
("Secrets:GrpcHub:BearerToken", DummyBearerToken));
|
||||
("Secrets:GrpcHub:BearerToken", DummyBearerToken),
|
||||
("Secrets:SqlServer:ConnectionString", DummyConnectionString));
|
||||
|
||||
Assert.True(HasHubEndpoint(app));
|
||||
}
|
||||
@@ -491,7 +604,8 @@ public class SecretsHubEndpointMappingTests
|
||||
using WebApplication app = BuildCentralApp(
|
||||
("Secrets:Replication:Enabled", "true"),
|
||||
("Secrets:Replication:Mode", "Grpc"),
|
||||
("Secrets:GrpcHub:BearerToken", DummyBearerToken));
|
||||
("Secrets:GrpcHub:BearerToken", DummyBearerToken),
|
||||
("Secrets:SqlServer:ConnectionString", DummyConnectionString));
|
||||
|
||||
Assert.Equal(
|
||||
[HubRoutePrefix + "GetManifest", HubRoutePrefix + "GetSecrets"],
|
||||
|
||||
Reference in New Issue
Block a user