diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsReplicationWiringTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsReplicationWiringTests.cs
index 89348a4a..e221e846 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsReplicationWiringTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsReplicationWiringTests.cs
@@ -1,8 +1,11 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
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.Sqlite;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
@@ -20,7 +23,9 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// Registration must not need a reachable SQL Server: the SQL-Server package validates its options
/// eagerly, but validation only checks that the connection string is non-empty and the schema name
/// is a legal identifier. Nothing connects until a sweep or a write runs, and these tests do
-/// neither — so the dummy connection string below never resolves to a real host.
+/// neither — so the dummy connection string below never resolves to a real host. The same holds for
+/// the gRPC hub client: GrpcChannel.ForAddress performs no I/O, so the dummy endpoint is
+/// never dialled either.
///
///
public class SecretsReplicationWiringTests
@@ -29,6 +34,12 @@ public class SecretsReplicationWiringTests
private const string DummyConnectionString =
"Server=unused;Database=x;Integrated Security=true;";
+ // Syntactically valid, deliberately unreachable. Never dialled by these tests.
+ private const string DummyHubEndpoint = "http://unused.invalid:8083";
+
+ // Not a credential — a non-blank placeholder, which is all the fail-closed validators check.
+ private const string DummyBearerToken = "test-hub-token";
+
private static IConfiguration BuildConfig(params (string Key, string Value)[] overrides)
{
var settings = new Dictionary
@@ -47,11 +58,13 @@ public class SecretsReplicationWiringTests
return new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
}
- private static ServiceProvider BuildProvider(IConfiguration config)
+ private static ServiceProvider BuildProvider(
+ IConfiguration config,
+ SecretsNodeRole role = SecretsNodeRole.Central)
{
var services = new ServiceCollection();
services.AddLogging();
- services.AddScadaBridgeSecrets(config);
+ services.AddScadaBridgeSecrets(config, role);
return services.BuildServiceProvider(validateScopes: true);
}
@@ -59,11 +72,40 @@ public class SecretsReplicationWiringTests
/// Any hosted service contributed by the SQL-Server replicator package. Asserted by assembly
/// rather than by type because SqlServerHubMigrationHostedService is internal to it.
///
- private static bool IsReplicatorHostedService(ServiceDescriptor descriptor) =>
+ private static bool IsSqlServerReplicatorHostedService(ServiceDescriptor descriptor) =>
descriptor.ServiceType == typeof(IHostedService)
&& descriptor.ImplementationType?.Assembly.GetName().Name
== "ZB.MOM.WW.Secrets.Replicator.SqlServer";
+ ///
+ /// The gRPC hub package's one hosted service — the follower sweep. Asserted by assembly and
+ /// type NAME because GrpcSecretSyncService is internal to the package. The hub SERVER
+ /// half contributes no hosted service at all, so presence of one from this assembly is
+ /// specifically the follower half and nothing else.
+ ///
+ private static bool IsGrpcHubSweepService(IHostedService service) =>
+ service.GetType().Assembly.GetName().Name == "ZB.MOM.WW.Secrets.Replicator.Grpc"
+ && service.GetType().Name == "GrpcSecretSyncService";
+
+ private static (string Key, string Value)[] GrpcCentralConfig() =>
+ [
+ ("Secrets:Replication:Enabled", "true"),
+ ("Secrets:Replication:Mode", "Grpc"),
+ ("Secrets:GrpcHub:BearerToken", DummyBearerToken),
+ ];
+
+ private static (string Key, string Value)[] GrpcSiteConfig() =>
+ [
+ ("Secrets:Replication:Enabled", "true"),
+ ("Secrets:Replication:Mode", "Grpc"),
+ ("Secrets:GrpcHub:Endpoint", DummyHubEndpoint),
+ ("Secrets:GrpcHub:BearerToken", DummyBearerToken),
+ ];
+
+ // ---------------------------------------------------------------------------------------
+ // Replication off — the default, and the shape every existing deployment is in.
+ // ---------------------------------------------------------------------------------------
+
[Fact]
public void ReplicationDisabledByDefault_ResolvesPlainSqliteStore()
{
@@ -80,11 +122,40 @@ public class SecretsReplicationWiringTests
{
var services = new ServiceCollection();
services.AddLogging();
- services.AddScadaBridgeSecrets(BuildConfig());
+ services.AddScadaBridgeSecrets(BuildConfig(), SecretsNodeRole.Central);
- Assert.DoesNotContain(services, IsReplicatorHostedService);
+ Assert.DoesNotContain(services, IsSqlServerReplicatorHostedService);
}
+ ///
+ /// Flag off ⇒ NEITHER half of the gRPC hub, on EITHER role — even with the hub section fully
+ /// populated. The mode key selects a transport; it must never be what turns replication on.
+ ///
+ [Theory]
+ [InlineData(SecretsNodeRole.Central)]
+ [InlineData(SecretsNodeRole.Site)]
+ public void ReplicationDisabled_WithGrpcModeAndFullHubConfig_WiresNeitherHubHalf(
+ SecretsNodeRole role)
+ {
+ IConfiguration config = BuildConfig(
+ ("Secrets:Replication:Enabled", "false"),
+ ("Secrets:Replication:Mode", "Grpc"),
+ ("Secrets:GrpcHub:Endpoint", DummyHubEndpoint),
+ ("Secrets:GrpcHub:BearerToken", DummyBearerToken));
+
+ using ServiceProvider provider = BuildProvider(config, role);
+
+ Assert.IsType(provider.GetRequiredService());
+ Assert.Null(provider.GetService());
+ Assert.DoesNotContain(provider.GetServices(), IsGrpcHubSweepService);
+ Assert.False(SecretsRegistration.UsesGrpcHub(config));
+ }
+
+ // ---------------------------------------------------------------------------------------
+ // SQL-Server mode — regression pins. Behaviour must be byte-for-byte what it was before the
+ // mode key existed, both when the mode is left unset and when it is named explicitly.
+ // ---------------------------------------------------------------------------------------
+
[Fact]
public void ReplicationEnabledWithoutConnectionString_StaysOnPlainSqliteStore()
{
@@ -96,20 +167,27 @@ public class SecretsReplicationWiringTests
IConfiguration config = BuildConfig(("Secrets:Replication:Enabled", "true"));
- services.AddScadaBridgeSecrets(config);
+ services.AddScadaBridgeSecrets(config, SecretsNodeRole.Central);
using ServiceProvider provider = services.BuildServiceProvider(validateScopes: true);
Assert.IsType(provider.GetRequiredService());
- Assert.DoesNotContain(services, IsReplicatorHostedService);
+ Assert.DoesNotContain(services, IsSqlServerReplicatorHostedService);
}
- [Fact]
- public void ReplicationEnabledWithConnectionString_ResolvesReplicatingStore()
+ [Theory]
+ [InlineData(null)]
+ [InlineData("SqlServer")]
+ public void ReplicationEnabledWithConnectionString_ResolvesReplicatingStore(string? mode)
{
- IConfiguration config = BuildConfig(
- ("Secrets:Replication:Enabled", "true"),
- ("Secrets:SqlServer:ConnectionString", DummyConnectionString));
+ IConfiguration config = mode is null
+ ? BuildConfig(
+ ("Secrets:Replication:Enabled", "true"),
+ ("Secrets:SqlServer:ConnectionString", DummyConnectionString))
+ : BuildConfig(
+ ("Secrets:Replication:Enabled", "true"),
+ ("Secrets:Replication:Mode", mode),
+ ("Secrets:SqlServer:ConnectionString", DummyConnectionString));
using ServiceProvider provider = BuildProvider(config);
@@ -142,9 +220,303 @@ public class SecretsReplicationWiringTests
("Secrets:Replication:Enabled", "true"),
("Secrets:SqlServer:ConnectionString", DummyConnectionString));
- services.AddScadaBridgeSecrets(config);
+ services.AddScadaBridgeSecrets(config, SecretsNodeRole.Central);
// Both the hub-schema migration service and the bidirectional sweep.
- Assert.Equal(2, services.Count(IsReplicatorHostedService));
+ Assert.Equal(2, services.Count(IsSqlServerReplicatorHostedService));
+ }
+
+ ///
+ /// SQL-Server mode is role-agnostic — both nodes of a hub topology sync bidirectionally
+ /// against the same shared database. The role parameter added for the gRPC hub must not have
+ /// quietly changed that.
+ ///
+ [Theory]
+ [InlineData(SecretsNodeRole.Central)]
+ [InlineData(SecretsNodeRole.Site)]
+ public void SqlServerMode_IsIdenticalOnBothRoles(SecretsNodeRole role)
+ {
+ IConfiguration config = BuildConfig(
+ ("Secrets:Replication:Enabled", "true"),
+ ("Secrets:Replication:Mode", "SqlServer"),
+ ("Secrets:SqlServer:ConnectionString", DummyConnectionString));
+
+ using ServiceProvider provider = BuildProvider(config, role);
+
+ Assert.IsType(provider.GetRequiredService());
+ Assert.Null(provider.GetService());
+ Assert.DoesNotContain(provider.GetServices(), IsGrpcHubSweepService);
+ }
+
+ // ---------------------------------------------------------------------------------------
+ // gRPC mode — central hosts, site follows, and neither does the other's job.
+ // ---------------------------------------------------------------------------------------
+
+ [Fact]
+ public void GrpcMode_Central_RegistersHubAndItsAuthInterceptor()
+ {
+ IConfiguration config = BuildConfig(GrpcCentralConfig());
+
+ using ServiceProvider provider = BuildProvider(config, SecretsNodeRole.Central);
+
+ // The fail-closed bearer gate must RESOLVE, not merely be listed: an interceptor that
+ // cannot be activated is one gRPC would fail to attach, which is an ungated hub.
+ Assert.NotNull(provider.GetService());
+ Assert.True(SecretsRegistration.UsesGrpcHub(config));
+
+ // The local store is still the plain one — pull-only means nothing decorates it.
+ Assert.IsType(provider.GetRequiredService());
+ }
+
+ [Fact]
+ public void GrpcMode_Central_DoesNotRegisterTheFollowerSweep()
+ {
+ // Central is the origin of every write. A sweep here would have it pull from itself, and
+ // on a misconfigured endpoint, from somebody else.
+ using ServiceProvider provider =
+ BuildProvider(BuildConfig(GrpcCentralConfig()), SecretsNodeRole.Central);
+
+ Assert.DoesNotContain(provider.GetServices(), IsGrpcHubSweepService);
+ }
+
+ [Fact]
+ public void GrpcMode_Site_RegistersTheFollowerSweep()
+ {
+ using ServiceProvider provider =
+ BuildProvider(BuildConfig(GrpcSiteConfig()), SecretsNodeRole.Site);
+
+ // Resolving the hosted services builds the sweep's whole graph — reader, keyed channel,
+ // local store — which is where a missing AddZbSecrets would surface.
+ Assert.Contains(provider.GetServices(), IsGrpcHubSweepService);
+ Assert.IsType(provider.GetRequiredService());
+ }
+
+ [Fact]
+ public void GrpcMode_Site_DoesNotHostTheHub()
+ {
+ // A site hosting the hub would serve central's secrets to anything holding the shared
+ // token, from inside the site network.
+ using ServiceProvider provider =
+ BuildProvider(BuildConfig(GrpcSiteConfig()), SecretsNodeRole.Site);
+
+ Assert.Null(provider.GetService());
+ }
+
+ // ---------------------------------------------------------------------------------------
+ // gRPC mode fails closed. There is no local-only fallback here, unlike SQL-Server mode:
+ // a site quietly serving secrets that never converge is the outcome the hub exists to prevent.
+ // ---------------------------------------------------------------------------------------
+
+ [Fact]
+ public void GrpcMode_Central_WithoutBearerToken_FailsAtRegistration()
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+
+ IConfiguration config = BuildConfig(
+ ("Secrets:Replication:Enabled", "true"),
+ ("Secrets:Replication:Mode", "Grpc"));
+
+ var ex = Assert.Throws(
+ () => services.AddScadaBridgeSecrets(config, SecretsNodeRole.Central));
+
+ // The package's own message, unwrapped, naming the exact key an operator has to set.
+ Assert.Contains("Secrets:GrpcHub:BearerToken", ex.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void GrpcMode_Site_WithoutEndpoint_FailsAtRegistration()
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+
+ IConfiguration config = BuildConfig(
+ ("Secrets:Replication:Enabled", "true"),
+ ("Secrets:Replication:Mode", "Grpc"),
+ ("Secrets:GrpcHub:BearerToken", DummyBearerToken));
+
+ var ex = Assert.Throws(
+ () => services.AddScadaBridgeSecrets(config, SecretsNodeRole.Site));
+
+ Assert.Contains("Secrets:GrpcHub:Endpoint", ex.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void GrpcMode_Site_WithoutBearerToken_FailsAtRegistration()
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+
+ IConfiguration config = BuildConfig(
+ ("Secrets:Replication:Enabled", "true"),
+ ("Secrets:Replication:Mode", "Grpc"),
+ ("Secrets:GrpcHub:Endpoint", DummyHubEndpoint));
+
+ var ex = Assert.Throws(
+ () => services.AddScadaBridgeSecrets(config, SecretsNodeRole.Site));
+
+ Assert.Contains("Secrets:GrpcHub:BearerToken", ex.Message, StringComparison.Ordinal);
+ }
+
+ // ---------------------------------------------------------------------------------------
+ // Mode key parsing.
+ // ---------------------------------------------------------------------------------------
+
+ [Theory]
+ [InlineData(null, SecretsReplicationMode.SqlServer)]
+ [InlineData("", SecretsReplicationMode.SqlServer)]
+ [InlineData("SqlServer", SecretsReplicationMode.SqlServer)]
+ [InlineData("sqlserver", SecretsReplicationMode.SqlServer)]
+ [InlineData("Grpc", SecretsReplicationMode.Grpc)]
+ [InlineData("grpc", SecretsReplicationMode.Grpc)]
+ [InlineData(" Grpc ", SecretsReplicationMode.Grpc)]
+ public void ResolveReplicationMode_ReadsTheKey(string? value, SecretsReplicationMode expected)
+ {
+ IConfiguration config = value is null
+ ? BuildConfig()
+ : BuildConfig(("Secrets:Replication:Mode", value));
+
+ Assert.Equal(expected, SecretsRegistration.ResolveReplicationMode(config));
+ }
+
+ [Fact]
+ public void ResolveReplicationMode_RejectsAnUnknownValue_EvenWithReplicationOff()
+ {
+ // Caught at the boot that introduces the typo rather than at whichever later boot flips
+ // Secrets:Replication:Enabled — otherwise an unrelated change fails on a key nobody touched.
+ IConfiguration config = BuildConfig(("Secrets:Replication:Mode", "GrpcHub"));
+
+ var ex = Assert.Throws(
+ () => SecretsRegistration.ResolveReplicationMode(config));
+
+ Assert.Contains("Secrets:Replication:Mode", ex.Message, StringComparison.Ordinal);
+ }
+
+ ///
+ /// A numeric value must not be accepted. Enum.TryParse happily parses any integer,
+ /// including ones outside the enum, so "2" would otherwise select a mode that does not exist
+ /// and fall through the switch to the SQL-Server branch.
+ ///
+ [Fact]
+ public void ResolveReplicationMode_RejectsAnOutOfRangeNumericValue()
+ {
+ IConfiguration config = BuildConfig(("Secrets:Replication:Mode", "7"));
+
+ Assert.Throws(
+ () => SecretsRegistration.ResolveReplicationMode(config));
+ }
+}
+
+///
+/// Endpoint-mapping pins for the central-hosted secrets hub.
+///
+/// Registration and mapping are gated by one shared predicate on purpose, and this asserts the
+/// consequence: the hub endpoint exists exactly when the node that serves it registered the
+/// fail-closed interceptor. A mapped-but-unregistered hub would be an anonymous endpoint serving
+/// every secret central holds, and it would look completely healthy.
+///
+///
+public class SecretsHubEndpointMappingTests
+{
+ private const string DummyBearerToken = "test-hub-token";
+
+ private static WebApplication BuildCentralApp(params (string Key, string Value)[] overrides)
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.Configuration.Sources.Clear();
+
+ var settings = new Dictionary
+ {
+ ["Secrets:SqlitePath"] = Path.Combine(
+ Path.GetTempPath(), $"sb-secrets-map-test-{Guid.NewGuid():N}.db"),
+ ["Secrets:MasterKey:Source"] = "Environment",
+ ["Secrets:MasterKey:EnvVarName"] = "ZB_SECRETS_MASTER_KEY",
+ ["Secrets:RunMigrationsOnStartup"] = "false",
+ };
+
+ foreach ((string key, string value) in overrides)
+ {
+ settings[key] = value;
+ }
+
+ builder.Configuration.AddInMemoryCollection(settings);
+ builder.Services.AddGrpc();
+ builder.Services.AddScadaBridgeSecrets(builder.Configuration, SecretsNodeRole.Central);
+
+ WebApplication app = builder.Build();
+ app.MapScadaBridgeSecretsHub(app.Configuration);
+ return app;
+ }
+
+ ///
+ /// The gRPC service's WIRE name, from package zb.mom.ww.secrets.hub.v1; service SecretsHub.
+ /// Matched on the route pattern rather than on the C# type, because the route is the thing a
+ /// follower actually addresses and the thing a rename would break.
+ ///
+ private const string HubRoutePrefix = "/zb.mom.ww.secrets.hub.v1.SecretsHub/";
+
+ private static IReadOnlyList HubRoutes(WebApplication app) =>
+ ((IEndpointRouteBuilder)app).DataSources
+ .SelectMany(source => source.Endpoints)
+ .OfType()
+ .Select(endpoint => "/" + endpoint.RoutePattern.RawText?.TrimStart('/'))
+ .Where(route => route.StartsWith(HubRoutePrefix, StringComparison.Ordinal))
+ // Grpc.AspNetCore always adds a "{unimplementedMethod}" catch-all per service, which
+ // answers Unimplemented. It is framework scaffolding, not an RPC the contract declares.
+ .Where(route => !route.Contains('{', StringComparison.Ordinal))
+ .ToList();
+
+ private static bool HasHubEndpoint(WebApplication app) => HubRoutes(app).Count > 0;
+
+ [Fact]
+ public void GrpcMode_Central_MapsTheHubEndpoint()
+ {
+ using WebApplication app = BuildCentralApp(
+ ("Secrets:Replication:Enabled", "true"),
+ ("Secrets:Replication:Mode", "Grpc"),
+ ("Secrets:GrpcHub:BearerToken", DummyBearerToken));
+
+ Assert.True(HasHubEndpoint(app));
+ }
+
+ ///
+ /// Pull-only is a property of the wire contract, and this is where it becomes observable to
+ /// ScadaBridge: central maps exactly the two READ methods and nothing that could accept a
+ /// pushed row. If a future package version adds a write RPC, this fails here rather than
+ /// silently opening a path for a site to overwrite central's secrets.
+ ///
+ [Fact]
+ public void GrpcMode_Central_MapsOnlyTheTwoReadMethods()
+ {
+ using WebApplication app = BuildCentralApp(
+ ("Secrets:Replication:Enabled", "true"),
+ ("Secrets:Replication:Mode", "Grpc"),
+ ("Secrets:GrpcHub:BearerToken", DummyBearerToken));
+
+ Assert.Equal(
+ [HubRoutePrefix + "GetManifest", HubRoutePrefix + "GetSecrets"],
+ HubRoutes(app).Order(StringComparer.Ordinal).ToArray());
+ }
+
+ [Fact]
+ public void ReplicationDisabled_MapsNoHubEndpoint()
+ {
+ using WebApplication app = BuildCentralApp(
+ ("Secrets:Replication:Enabled", "false"),
+ ("Secrets:Replication:Mode", "Grpc"),
+ ("Secrets:GrpcHub:BearerToken", DummyBearerToken));
+
+ Assert.False(HasHubEndpoint(app));
+ }
+
+ [Fact]
+ public void SqlServerMode_MapsNoHubEndpoint()
+ {
+ using WebApplication app = BuildCentralApp(
+ ("Secrets:Replication:Enabled", "true"),
+ ("Secrets:Replication:Mode", "SqlServer"),
+ ("Secrets:SqlServer:ConnectionString", "Server=unused;Database=x;Integrated Security=true;"));
+
+ Assert.False(HasHubEndpoint(app));
}
}