test(secrets): pin both hub halves, the mode key, and the mapping gate

Extends the existing build-and-resolve wiring suite rather than asserting over
ServiceDescriptors, for the reason that file already documents: a registration
can look correct as a descriptor list and still fail on first resolve.

Role separation is pinned in both directions — central registers the hub's
fail-closed interceptor and NOT the sweep, a site registers the sweep and NOT
the hub — because only one of those is a security failure and testing the
happy half would not catch it. The sweep is asserted by resolving IHostedService,
which builds its whole graph (reader, keyed channel, local store) and is where
a forgotten AddZbSecrets would surface; GrpcSecretSyncService is internal to
the package, so it is matched by assembly + type name the way the SqlServer
replicator's services already are.

SqlServer mode gets regression pins with the mode key both unset and named
explicitly, plus one asserting it stays role-agnostic — both nodes sync
bidirectionally against the same database, and the role parameter added for the
hub must not have quietly changed that.

The mapping tests assert over the app's real endpoint data sources, on the WIRE
route (/zb.mom.ww.secrets.hub.v1.SecretsHub/...) rather than the C# type, since
the route is what a follower addresses. One of them pins exactly the two READ
methods: pull-only is a property of the contract, and this is where a future
package version growing a write RPC would become visible instead of silently
opening a path for a site to overwrite central.

Numeric mode values get their own test. Enum.TryParse accepts any integer,
including ones outside the enum, so "7" would otherwise select a mode that does
not exist and fall through to the SqlServer branch.

Verified red-first by mutation on the finished implementation: swapping the two
role branches reds 8 (both role pins, both fail-closed pins, both mapping
pins); deleting the UsesGrpcHub check in the map extension reds exactly the two
"maps no hub endpoint" cases — the unauthenticated-hub scenario; dropping
Enum.IsDefined and letting UsesGrpcHub ignore Enabled reds the out-of-range
value and the flag-off-with-full-hub-config cases. 31/31 green restored.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
This commit is contained in:
Joseph Doherty
2026-08-07 07:09:47 -04:00
parent 127ec25425
commit c8e90daafb
@@ -1,8 +1,11 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using ZB.MOM.WW.Secrets.Abstractions; using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Replication; using ZB.MOM.WW.Secrets.Replication;
using ZB.MOM.WW.Secrets.Replicator.Grpc;
using ZB.MOM.WW.Secrets.Sqlite; using ZB.MOM.WW.Secrets.Sqlite;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests; 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 /// 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 /// 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 /// 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: <c>GrpcChannel.ForAddress</c> performs no I/O, so the dummy endpoint is
/// never dialled either.
/// </para> /// </para>
/// </summary> /// </summary>
public class SecretsReplicationWiringTests public class SecretsReplicationWiringTests
@@ -29,6 +34,12 @@ public class SecretsReplicationWiringTests
private const string DummyConnectionString = private const string DummyConnectionString =
"Server=unused;Database=x;Integrated Security=true;"; "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) private static IConfiguration BuildConfig(params (string Key, string Value)[] overrides)
{ {
var settings = new Dictionary<string, string?> var settings = new Dictionary<string, string?>
@@ -47,11 +58,13 @@ public class SecretsReplicationWiringTests
return new ConfigurationBuilder().AddInMemoryCollection(settings).Build(); 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(); var services = new ServiceCollection();
services.AddLogging(); services.AddLogging();
services.AddScadaBridgeSecrets(config); services.AddScadaBridgeSecrets(config, role);
return services.BuildServiceProvider(validateScopes: true); 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 /// Any hosted service contributed by the SQL-Server replicator package. Asserted by assembly
/// rather than by type because <c>SqlServerHubMigrationHostedService</c> is internal to it. /// rather than by type because <c>SqlServerHubMigrationHostedService</c> is internal to it.
/// </summary> /// </summary>
private static bool IsReplicatorHostedService(ServiceDescriptor descriptor) => private static bool IsSqlServerReplicatorHostedService(ServiceDescriptor descriptor) =>
descriptor.ServiceType == typeof(IHostedService) descriptor.ServiceType == typeof(IHostedService)
&& descriptor.ImplementationType?.Assembly.GetName().Name && descriptor.ImplementationType?.Assembly.GetName().Name
== "ZB.MOM.WW.Secrets.Replicator.SqlServer"; == "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
/// half contributes no hosted service at all, so presence of one from this assembly is
/// specifically the follower half and nothing else.
/// </summary>
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] [Fact]
public void ReplicationDisabledByDefault_ResolvesPlainSqliteStore() public void ReplicationDisabledByDefault_ResolvesPlainSqliteStore()
{ {
@@ -80,11 +122,40 @@ public class SecretsReplicationWiringTests
{ {
var services = new ServiceCollection(); var services = new ServiceCollection();
services.AddLogging(); services.AddLogging();
services.AddScadaBridgeSecrets(BuildConfig()); services.AddScadaBridgeSecrets(BuildConfig(), SecretsNodeRole.Central);
Assert.DoesNotContain(services, IsReplicatorHostedService); Assert.DoesNotContain(services, IsSqlServerReplicatorHostedService);
} }
/// <summary>
/// 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.
/// </summary>
[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<SqliteSecretStore>(provider.GetRequiredService<ISecretStore>());
Assert.Null(provider.GetService<SecretsHubAuthInterceptor>());
Assert.DoesNotContain(provider.GetServices<IHostedService>(), 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] [Fact]
public void ReplicationEnabledWithoutConnectionString_StaysOnPlainSqliteStore() public void ReplicationEnabledWithoutConnectionString_StaysOnPlainSqliteStore()
{ {
@@ -96,20 +167,27 @@ public class SecretsReplicationWiringTests
IConfiguration config = BuildConfig(("Secrets:Replication:Enabled", "true")); IConfiguration config = BuildConfig(("Secrets:Replication:Enabled", "true"));
services.AddScadaBridgeSecrets(config); services.AddScadaBridgeSecrets(config, SecretsNodeRole.Central);
using ServiceProvider provider = services.BuildServiceProvider(validateScopes: true); using ServiceProvider provider = services.BuildServiceProvider(validateScopes: true);
Assert.IsType<SqliteSecretStore>(provider.GetRequiredService<ISecretStore>()); Assert.IsType<SqliteSecretStore>(provider.GetRequiredService<ISecretStore>());
Assert.DoesNotContain(services, IsReplicatorHostedService); Assert.DoesNotContain(services, IsSqlServerReplicatorHostedService);
} }
[Fact] [Theory]
public void ReplicationEnabledWithConnectionString_ResolvesReplicatingStore() [InlineData(null)]
[InlineData("SqlServer")]
public void ReplicationEnabledWithConnectionString_ResolvesReplicatingStore(string? mode)
{ {
IConfiguration config = BuildConfig( IConfiguration config = mode is null
("Secrets:Replication:Enabled", "true"), ? BuildConfig(
("Secrets:SqlServer:ConnectionString", DummyConnectionString)); ("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); using ServiceProvider provider = BuildProvider(config);
@@ -142,9 +220,303 @@ public class SecretsReplicationWiringTests
("Secrets:Replication:Enabled", "true"), ("Secrets:Replication:Enabled", "true"),
("Secrets:SqlServer:ConnectionString", DummyConnectionString)); ("Secrets:SqlServer:ConnectionString", DummyConnectionString));
services.AddScadaBridgeSecrets(config); services.AddScadaBridgeSecrets(config, SecretsNodeRole.Central);
// Both the hub-schema migration service and the bidirectional sweep. // Both the hub-schema migration service and the bidirectional sweep.
Assert.Equal(2, services.Count(IsReplicatorHostedService)); Assert.Equal(2, services.Count(IsSqlServerReplicatorHostedService));
}
/// <summary>
/// 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.
/// </summary>
[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<ReplicatingSecretStore>(provider.GetRequiredService<ISecretStore>());
Assert.Null(provider.GetService<SecretsHubAuthInterceptor>());
Assert.DoesNotContain(provider.GetServices<IHostedService>(), 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<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>());
}
[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<IHostedService>(), 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<IHostedService>(), IsGrpcHubSweepService);
Assert.IsType<SqliteSecretStore>(provider.GetRequiredService<ISecretStore>());
}
[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<SecretsHubAuthInterceptor>());
}
// ---------------------------------------------------------------------------------------
// 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<InvalidOperationException>(
() => 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<InvalidOperationException>(
() => 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<InvalidOperationException>(
() => 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<InvalidOperationException>(
() => SecretsRegistration.ResolveReplicationMode(config));
Assert.Contains("Secrets:Replication:Mode", ex.Message, StringComparison.Ordinal);
}
/// <summary>
/// A numeric value must not be accepted. <c>Enum.TryParse</c> 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.
/// </summary>
[Fact]
public void ResolveReplicationMode_RejectsAnOutOfRangeNumericValue()
{
IConfiguration config = BuildConfig(("Secrets:Replication:Mode", "7"));
Assert.Throws<InvalidOperationException>(
() => SecretsRegistration.ResolveReplicationMode(config));
}
}
/// <summary>
/// Endpoint-mapping pins for the central-hosted secrets hub.
/// <para>
/// 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.
/// </para>
/// </summary>
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<string, string?>
{
["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;
}
/// <summary>
/// The gRPC service's WIRE name, from <c>package zb.mom.ww.secrets.hub.v1; service SecretsHub</c>.
/// 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.
/// </summary>
private const string HubRoutePrefix = "/zb.mom.ww.secrets.hub.v1.SecretsHub/";
private static IReadOnlyList<string> HubRoutes(WebApplication app) =>
((IEndpointRouteBuilder)app).DataSources
.SelectMany(source => source.Endpoints)
.OfType<RouteEndpoint>()
.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));
}
/// <summary>
/// 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.
/// </summary>
[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));
} }
} }