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)); } }