using Grpc.Net.Client; 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.Replicator.Grpc.DependencyInjection; using ZB.MOM.WW.Secrets.Replicator.SqlServer; using ZB.MOM.WW.Secrets.Sqlite; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// /// DI-resolution tests for . /// /// These deliberately BUILD a container and RESOLVE out of it rather than asserting over /// s. A decorator registration can look perfectly correct as a /// descriptor list and still throw on the first resolve because the undecorated concrete store it /// depends on is missing — that exact gap shipped once in this library, with every descriptor-level /// unit test green. Resolution is the only assertion that catches it. /// /// /// 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. 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 { // Syntactically valid, deliberately unreachable. Never connected to by these tests. 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"; // Syntactically valid, deliberately unreachable. Never dialled by these tests. private const string DummyFallbackHubEndpoint = "http://unused-fallback.invalid:8083"; /// /// Keyed service key of the dialing fallback endpoint 0 — the 0.5.0 /// package contract: the primary channel keeps /// unchanged and fallback /// i gets :fallback:i appended. /// private const string FallbackChannelKey = SecretsGrpcHubClientExtensions.ChannelServiceKey + ":fallback:0"; /// /// The package-internal reader seam the follower sweep pulls through /// (ISecretsHubReader). Internal to the package, so it is obtained by full name and /// resolved by — visibility never gates DI resolution, only compile-time /// references. /// private static readonly Type HubReaderInterface = typeof(SecretsHubAuthInterceptor).Assembly .GetType("ZB.MOM.WW.Secrets.Replicator.Grpc.ISecretsHubReader", throwOnError: true)!; // 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 { ["Secrets:SqlitePath"] = Path.Combine(Path.GetTempPath(), "sb-secrets-wiring-test.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; } return new ConfigurationBuilder().AddInMemoryCollection(settings).Build(); } private static ServiceProvider BuildProvider( IConfiguration config, SecretsNodeRole role = SecretsNodeRole.Central) { var services = new ServiceCollection(); services.AddLogging(); services.AddScadaBridgeSecrets(config, role); return services.BuildServiceProvider(validateScopes: true); } /// /// 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 IsSqlServerReplicatorHostedService(ServiceDescriptor descriptor) => descriptor.ServiceType == typeof(IHostedService) && descriptor.ImplementationType?.Assembly.GetName().Name == "ZB.MOM.WW.Secrets.Replicator.SqlServer"; /// /// Any descriptor whose service OR implementation type lives in the SQL-Server replicator /// assembly. Honest bound: the package also registers ISecretStore/ISecretsStoreMigrator /// through factory lambdas whose service type is the Abstractions interface and whose /// ImplementationType is null — those two descriptors would evade this scan in isolation. The /// site-purity pin still holds because the same extension unconditionally registers three /// concrete types from the target assembly first, which this scan does catch — so the package /// cannot enter the container without tripping it, even though not every individual descriptor /// it adds is individually detectable. /// 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"; /// /// 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"; /// /// 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. /// 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() => [ ("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() { using ServiceProvider provider = BuildProvider(BuildConfig()); var store = provider.GetRequiredService(); Assert.IsType(store); Assert.IsNotType(store); } [Fact] public void ReplicationDisabledByDefault_RegistersNoReplicatorHostedServices() { var services = new ServiceCollection(); services.AddLogging(); services.AddScadaBridgeSecrets(BuildConfig(), SecretsNodeRole.Central); 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() { // The gate requires BOTH the flag and a connection string. Enabling the flag alone must not // reach the SQL-Server package, whose options validation would throw at registration and // take the whole node down at startup. var services = new ServiceCollection(); services.AddLogging(); IConfiguration config = BuildConfig(("Secrets:Replication:Enabled", "true")); services.AddScadaBridgeSecrets(config, SecretsNodeRole.Central); using ServiceProvider provider = services.BuildServiceProvider(validateScopes: true); Assert.IsType(provider.GetRequiredService()); Assert.DoesNotContain(services, IsSqlServerReplicatorHostedService); } [Theory] [InlineData(null)] [InlineData("SqlServer")] public void ReplicationEnabledWithConnectionString_ResolvesReplicatingStore(string? mode) { 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); Assert.IsType(provider.GetRequiredService()); } [Fact] public void ReplicationEnabledWithConnectionString_UndecoratedLocalStoreAlsoResolves() { // ReplicatingSecretStore is constructed from the CONCRETE SqliteSecretStore, not from // ISecretStore (which is the decorator itself — that would recurse). If the concrete // registration were ever dropped, the decorator would fail on first resolve. This is the // exact defect that shipped once, so it gets its own test. IConfiguration config = BuildConfig( ("Secrets:Replication:Enabled", "true"), ("Secrets:SqlServer:ConnectionString", DummyConnectionString)); using ServiceProvider provider = BuildProvider(config); Assert.NotNull(provider.GetRequiredService()); } [Fact] public void ReplicationEnabledWithConnectionString_RegistersReplicatorHostedServices() { var services = new ServiceCollection(); services.AddLogging(); IConfiguration config = BuildConfig( ("Secrets:Replication:Enabled", "true"), ("Secrets:SqlServer:ConnectionString", DummyConnectionString)); services.AddScadaBridgeSecrets(config, SecretsNodeRole.Central); // Both the hub-schema migration service and the bidirectional sweep. 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)); // 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(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()); } /// /// A configured fallback endpoint swaps the sweep's reader for the package's failover /// composition and adds one keyed channel per fallback. Safe ONLY because both central nodes /// serve one shared SQL store (scadaproj#4) — the type-name assertion is deliberate: the /// failover reader is internal to the package, and its name is the observable contract here. /// [Fact] public void GrpcMode_Site_WithFallbackEndpoint_ResolvesTheFailoverReader() { IConfiguration config = BuildConfig( [ .. GrpcSiteConfig(), ("Secrets:GrpcHub:FallbackEndpoints:0", DummyFallbackHubEndpoint), ]); using ServiceProvider provider = BuildProvider(config, SecretsNodeRole.Site); object reader = provider.GetRequiredService(HubReaderInterface); Assert.Equal("FailoverSecretsHubReader", reader.GetType().Name); Assert.NotNull(provider.GetKeyedService(FallbackChannelKey)); } /// /// Zero fallbacks pins the unchanged default: the reader stays the plain single-endpoint /// client and no fallback channel enters the container — exactly the pre-0.5.0 shape every /// existing deployment is in. /// [Fact] public void GrpcMode_Site_WithoutFallbackEndpoints_KeepsThePlainHubClient() { using ServiceProvider provider = BuildProvider(BuildConfig(GrpcSiteConfig()), SecretsNodeRole.Site); object reader = provider.GetRequiredService(HubReaderInterface); Assert.Equal("GrpcSecretsHubClient", reader.GetType().Name); Assert.Null(provider.GetKeyedService(FallbackChannelKey)); } // --------------------------------------------------------------------------------------- // 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(); // 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:SqlServer:ConnectionString", DummyConnectionString)); 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); } /// /// 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. /// [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( () => services.AddScadaBridgeSecrets(BuildConfig(overrides), SecretsNodeRole.Central)); Assert.Contains( SecretsRegistration.HubConnectionStringKey, ex.Message, StringComparison.Ordinal); } /// /// The store's own connection string can never be a ${secret:} 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. /// [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( () => 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); } /// /// 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. /// [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(provider.GetRequiredService()); Assert.Null(provider.GetService()); } [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; // 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:SqlServer:ConnectionString", DummyConnectionString)); 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), ("Secrets:SqlServer:ConnectionString", DummyConnectionString)); 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)); } }