feat(secrets)!: retire the gRPC secrets hub in ScadaBridge — sites get secrets via Deploy Artifacts; rig central on the shared SQL store

This commit is contained in:
Joseph Doherty
2026-09-28 19:52:26 -04:00
parent 52cd4fb096
commit 2bac13170c
11 changed files with 333 additions and 1181 deletions
@@ -5,12 +5,8 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.LocalDb.Replication;
using ZB.MOM.WW.Secrets.Replicator.Grpc;
using ZB.MOM.WW.Secrets.Replicator.Grpc.DependencyInjection;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
@@ -21,14 +17,7 @@ public class MappedGrpcAuthenticationCoverageTests
public async Task EveryMappedGrpcService_IsCoveredIncludingExternalMappings()
{
var builder = WebApplication.CreateBuilder();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Secrets:Replication:Enabled"] = "true",
["Secrets:Replication:Mode"] = "Grpc",
["Secrets:GrpcHub:BearerToken"] = "coverage-test-token",
});
builder.Services.AddGrpc();
builder.Services.AddZbSecretsGrpcHub(builder.Configuration, "Secrets:GrpcHub");
await using var app = builder.Build();
// Discover the production map calls, not a hand-maintained three-service type list.
@@ -51,17 +40,19 @@ public class MappedGrpcAuthenticationCoverageTests
map.MakeGenericMethod(type).Invoke(null, [app]);
}
// Exercise the production extension mappings too; these packages hide their MapGrpcService calls.
// Exercise the production extension mappings too; this package hides its MapGrpcService call.
Assert.Contains(hostSources.SelectMany(s => s.DescendantNodes().OfType<InvocationExpressionSyntax>()),
n => n.Expression.ToString().EndsWith(".MapZbLocalDbSync", StringComparison.Ordinal));
app.MapZbLocalDbSync();
app.MapScadaBridgeSecretsHub(builder.Configuration);
var metadata = ((IEndpointRouteBuilder)app).DataSources.SelectMany(s => s.Endpoints)
.Select(e => e.Metadata.GetMetadata<GrpcMethodMetadata>()).Where(m => m is not null).ToList();
Assert.NotEmpty(metadata);
var serviceTypes = metadata.Select(m => m!.ServiceType).Distinct().ToList();
Assert.True(serviceTypes.Count >= typeNames.Count + 2, "External LocalDb and Secrets mappings must both be exercised.");
Assert.True(serviceTypes.Count >= typeNames.Count + 1, "The external LocalDb mapping must also be exercised.");
// The secrets hub — which used to install an all-method interceptor on its own service
// options and so needed a separate gate check here — is retired (Task 11): there is no
// longer a mapped gRPC service whose only gate is anything other than one of these prefixes.
var prefixes = ControlPlaneAuthInterceptor.DefaultGatedPrefixes
.Concat(CentralControlAuthInterceptor.DefaultGatedPrefixes)
.Append((string)typeof(LocalDbSyncAuthInterceptor).GetField("ServicePrefix", BindingFlags.Static | BindingFlags.NonPublic)!.GetRawConstantValue()!)
@@ -78,21 +69,14 @@ public class MappedGrpcAuthenticationCoverageTests
Assert.NotEmpty(binder.Methods);
Assert.Equal(metadata.Where(m => m!.ServiceType == type).Select(m => m!.Method.FullName).Order(), binder.Methods.Order());
// Secrets installs an all-method interceptor on its own service options. Inspect actual DI options.
var optionsType = typeof(IOptions<>).MakeGenericType(typeof(GrpcServiceOptions<>).MakeGenericType(type));
var options = app.Services.GetRequiredService(optionsType);
var serviceOptions = (GrpcServiceOptions)optionsType.GetProperty("Value")!.GetValue(options)!;
var hasSecretsGate = serviceOptions.Interceptors.Any(i => i.Type == typeof(SecretsHubAuthInterceptor));
Assert.All(binder.Methods, method => Assert.True(
hasSecretsGate || prefixes.Any(prefix => method.StartsWith(prefix, StringComparison.Ordinal)),
prefixes.Any(prefix => method.StartsWith(prefix, StringComparison.Ordinal)),
$"Mapped service {type.FullName} method {method} has no authentication interceptor gate."));
if (!hasSecretsGate)
{
// Negative control uses the real service's methods: removing its gate must be detected.
var removed = prefixes.First(p => binder.Methods.Any(m => m.StartsWith(p, StringComparison.Ordinal)));
var remaining = prefixes.Where(p => p != removed).ToArray();
Assert.Contains(binder.Methods, method => !remaining.Any(p => method.StartsWith(p, StringComparison.Ordinal)));
}
// Negative control uses the real service's methods: removing its gate must be detected.
var removed = prefixes.First(p => binder.Methods.Any(m => m.StartsWith(p, StringComparison.Ordinal)));
var remaining = prefixes.Where(p => p != removed).ToArray();
Assert.Contains(binder.Methods, method => !remaining.Any(p => method.StartsWith(p, StringComparison.Ordinal)));
}
}
@@ -1,13 +1,6 @@
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;
@@ -16,6 +9,14 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// DI-resolution tests for <see cref="SecretsRegistration.AddScadaBridgeSecrets"/>.
/// <para>
/// Task 11 retired every secrets REPLICATION mode in ScadaBridge — the pull-only gRPC hub (central
/// hosts, sites sweep) and the SQL-Server <c>ReplicatingSecretStore</c> mode that preceded it — on
/// BOTH roles. What is left: the plain local SQLite store, which is every node's default and now a
/// Site's ONLY possible shape, and the still-supported Task 5
/// <see cref="SecretsRegistration.StoreProviderKey"/>=SqlServer direct shared store (Central-only,
/// no hub, no follower sweep — just two nodes reading and writing one database).
/// </para>
/// <para>
/// These deliberately BUILD a container and RESOLVE out of it rather than asserting over
/// <see cref="ServiceDescriptor"/>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
@@ -25,10 +26,8 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <para>
/// 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: <c>GrpcChannel.ForAddress</c> performs no I/O, so the dummy endpoint is
/// never dialled either.
/// is a legal identifier. Nothing connects until a write or read runs, and these tests do neither —
/// so the dummy connection string below never resolves to a real host.
/// </para>
/// </summary>
public class SecretsReplicationWiringTests
@@ -37,33 +36,6 @@ 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";
// Syntactically valid, deliberately unreachable. Never dialled by these tests.
private const string DummyFallbackHubEndpoint = "http://unused-fallback.invalid:8083";
/// <summary>
/// Keyed service key of the <see cref="GrpcChannel"/> dialing fallback endpoint 0 — the 0.5.0
/// package contract: the primary channel keeps
/// <see cref="SecretsGrpcHubClientExtensions.ChannelServiceKey"/> unchanged and fallback
/// <c>i</c> gets <c>:fallback:i</c> appended.
/// </summary>
private const string FallbackChannelKey =
SecretsGrpcHubClientExtensions.ChannelServiceKey + ":fallback:0";
/// <summary>
/// The package-internal reader seam the follower sweep pulls through
/// (<c>ISecretsHubReader</c>). Internal to the package, so it is obtained by full name and
/// resolved by <see cref="Type"/> — visibility never gates DI resolution, only compile-time
/// references.
/// </summary>
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<string, string?>
@@ -93,116 +65,102 @@ public class SecretsReplicationWiringTests
}
/// <summary>
/// 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.
/// </summary>
private static bool IsSqlServerReplicatorHostedService(ServiceDescriptor descriptor) =>
descriptor.ServiceType == typeof(IHostedService)
&& descriptor.ImplementationType?.Assembly.GetName().Name
== "ZB.MOM.WW.Secrets.Replicator.SqlServer";
/// <summary>
/// Any descriptor whose service OR implementation type lives in the SQL-Server replicator
/// assembly. Honest bound: the package also registers <c>ISecretStore</c>/<c>ISecretsStoreMigrator</c>
/// Any descriptor whose service OR implementation type lives in the SQL-Server package.
/// Honest bound: the package also registers <c>ISecretStore</c>/<c>ISecretsStoreMigrator</c>
/// 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.
/// site-purity pin still holds because the same extension unconditionally registers 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.
/// </summary>
private static bool IsFromSqlServerReplicatorPackage(ServiceDescriptor descriptor) =>
private static bool IsFromSqlServerPackage(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
/// 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";
/// <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() =>
[
("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.
// Site: the ONLY shape left, on every configuration — plain local SQLite, never a
// replication decorator (Task 11, ruling #1). DeployedSecretsMirror deletes a name from
// this store directly the moment a deploy artifact stops referencing it; a decorator would
// publish that delete to every other node sharing whatever it decorates against, turning
// one site's unreferencing of a secret into a fleet-wide deletion.
// ---------------------------------------------------------------------------------------
[Fact]
public void ReplicationDisabledByDefault_ResolvesPlainSqliteStore()
public void Site_ResolvesPlainSqliteStore_NeverAReplicationDecorator()
{
using ServiceProvider provider = BuildProvider(BuildConfig());
using ServiceProvider provider = BuildProvider(BuildConfig(), SecretsNodeRole.Site);
var store = provider.GetRequiredService<ISecretStore>();
// Assert.IsType checks the EXACT runtime type, not merely "is a SqliteSecretStore or a
// decorator wrapping one" — nothing may sit between the site and its local file.
Assert.IsType<SqliteSecretStore>(store);
Assert.IsNotType<ReplicatingSecretStore>(store);
}
[Fact]
public void ReplicationDisabledByDefault_RegistersNoReplicatorHostedServices()
public void Site_IgnoresStoreProvider_EvenSetToSqlServer()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddScadaBridgeSecrets(BuildConfig(), SecretsNodeRole.Central);
// A Site node ignores Secrets:Store:Provider entirely and always keeps its local SQLite
// store — even with the value set to SqlServer and no connection string configured at
// all, which would fail closed on Central.
IConfiguration config = BuildConfig(("Secrets:Store:Provider", "SqlServer"));
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);
using ServiceProvider provider = BuildProvider(config, SecretsNodeRole.Site);
Assert.IsType<SqliteSecretStore>(provider.GetRequiredService<ISecretStore>());
Assert.Null(provider.GetService<SecretsHubAuthInterceptor>());
Assert.DoesNotContain(provider.GetServices<IHostedService>(), IsGrpcHubSweepService);
Assert.False(SecretsRegistration.UsesGrpcHub(config));
}
[Fact]
public void Site_HasNoSqlServerPackageTypesInTheContainer()
{
// The sites-don't-talk-to-SQL pin: a site node's container must contain NOTHING from the
// SQL-Server package — not just resolve a SQLite store, but hold no descriptor that could
// ever open a connection to central's database.
var services = new ServiceCollection();
services.AddLogging();
services.AddScadaBridgeSecrets(BuildConfig(), SecretsNodeRole.Site);
Assert.DoesNotContain(services, IsFromSqlServerPackage);
using ServiceProvider provider = services.BuildServiceProvider(validateScopes: true);
Assert.IsType<SqliteSecretStore>(provider.GetRequiredService<ISecretStore>());
Assert.Null(provider.GetService<SqlServerSecretStore>());
}
// ---------------------------------------------------------------------------------------
// Central, default — plain local SQLite, the shape every existing deployment is in.
// ---------------------------------------------------------------------------------------
[Fact]
public void Central_DefaultsToPlainSqliteStore()
{
using ServiceProvider provider = BuildProvider(BuildConfig());
Assert.IsType<SqliteSecretStore>(provider.GetRequiredService<ISecretStore>());
}
[Fact]
public void Central_StoreProviderSqlite_IsIndistinguishableFromUnset()
{
using ServiceProvider explicitProvider =
BuildProvider(BuildConfig(("Secrets:Store:Provider", "Sqlite")));
using ServiceProvider unsetProvider = BuildProvider(BuildConfig());
Assert.IsType<SqliteSecretStore>(explicitProvider.GetRequiredService<ISecretStore>());
Assert.IsType<SqliteSecretStore>(unsetProvider.GetRequiredService<ISecretStore>());
}
// ---------------------------------------------------------------------------------------
// Secrets:Store:Provider=SqlServer — a clustered central sharing ONE SQL secret store
// directly, without standing up the gRPC hub (task 5). Independent of Replication/Mode.
// directly, without a hub (task 5). The one replication-adjacent feature Task 11 left
// standing, because it is a plain shared database, not a converging replica set.
// ---------------------------------------------------------------------------------------
[Fact]
public void Central_StoreProviderSqlServer_UsesSharedSqlStore_WithoutHub()
public void Central_StoreProviderSqlServer_UsesSharedSqlStore()
{
IConfiguration config = BuildConfig(
("Secrets:Store:Provider", "SqlServer"),
@@ -211,12 +169,6 @@ public class SecretsReplicationWiringTests
using ServiceProvider provider = BuildProvider(config, SecretsNodeRole.Central);
Assert.IsType<SqlServerSecretStore>(provider.GetRequiredService<ISecretStore>());
// No hub: neither the fail-closed bearer interceptor nor the follower sweep is wired,
// and UsesGrpcHub — the single predicate MapScadaBridgeSecretsHub also checks — is false.
Assert.Null(provider.GetService<SecretsHubAuthInterceptor>());
Assert.DoesNotContain(provider.GetServices<IHostedService>(), IsGrpcHubSweepService);
Assert.False(SecretsRegistration.UsesGrpcHub(config));
}
[Fact]
@@ -230,17 +182,18 @@ public class SecretsReplicationWiringTests
var ex = Assert.Throws<InvalidOperationException>(
() => services.AddScadaBridgeSecrets(config, SecretsNodeRole.Central));
// Same helper, same message style as the Grpc-mode central check — but this path's
// message must name ITS OWN trigger (review round 1, finding #2), never claim
// Secrets:Replication:Mode=Grpc, which is not in effect here.
Assert.Contains(
SecretsRegistration.HubConnectionStringKey, ex.Message, StringComparison.Ordinal);
Assert.Contains(
$"{SecretsRegistration.StoreProviderKey}=SqlServer", ex.Message, StringComparison.Ordinal);
Assert.DoesNotContain(
$"{SecretsRegistration.ReplicationModeKey}=Grpc", 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). Rejected by name rather than left to fail as a malformed connection string on
/// first use.
/// </summary>
[Fact]
public void Central_StoreProviderSqlServer_SecretReferenceConnectionString_Throws()
{
@@ -254,324 +207,6 @@ public class SecretsReplicationWiringTests
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);
Assert.DoesNotContain("hub-connection-string", ex.Message, StringComparison.Ordinal);
}
[Fact]
public void Site_IgnoresStoreProvider()
{
// A Site node ignores Secrets:Store:Provider entirely and always keeps its local SQLite
// store — even with the value set to SqlServer and no connection string configured at
// all, which would fail closed on Central.
IConfiguration config = BuildConfig(("Secrets:Store:Provider", "SqlServer"));
using ServiceProvider provider = BuildProvider(config, SecretsNodeRole.Site);
Assert.IsType<SqliteSecretStore>(provider.GetRequiredService<ISecretStore>());
}
/// <summary>
/// Precedence pin (review round 1, finding #3): with BOTH triggers configured, the
/// pre-existing <c>Secrets:Replication:Enabled=true</c> Grpc-hub topology wins, and the new
/// Store:Provider=SqlServer branch defers to it rather than skipping hub registration. The
/// Store:Provider branch's own guard (<c>!ReplicationEnabledKey</c>) is what makes this so —
/// this test locks the ordering in against a future refactor of
/// <see cref="SecretsRegistration.AddScadaBridgeSecrets"/> that reorders the two branches.
/// </summary>
[Fact]
public void Central_ReplicationEnabledGrpcMode_TakesPrecedenceOverStoreProvider()
{
IConfiguration config = BuildConfig(
[
.. GrpcCentralConfig(),
("Secrets:Store:Provider", "SqlServer"),
]);
using ServiceProvider provider = BuildProvider(config, SecretsNodeRole.Central);
// Same shared SQL-Server store either way, but the Grpc hub is ALSO wired here — the
// Store:Provider-only path never registers the interceptor or the follower sweep.
Assert.IsType<SqlServerSecretStore>(provider.GetRequiredService<ISecretStore>());
Assert.NotNull(provider.GetService<SecretsHubAuthInterceptor>());
Assert.True(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<SqliteSecretStore>(provider.GetRequiredService<ISecretStore>());
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<ReplicatingSecretStore>(provider.GetRequiredService<ISecretStore>());
}
[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<SqliteSecretStore>());
}
[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));
}
/// <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));
// 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]
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>());
}
/// <summary>
/// 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.
/// </summary>
[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<GrpcChannel>(FallbackChannelKey));
}
/// <summary>
/// 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.
/// </summary>
[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<GrpcChannel>(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<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);
}
/// <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);
@@ -579,227 +214,4 @@ public class SecretsReplicationWiringTests
// 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()
{
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;
// 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));
}
/// <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),
("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));
}
}
@@ -771,23 +771,58 @@ public class StartupValidatorTests
Assert.Empty(warnings);
}
[Fact]
public void ClusteredCentral_ReplicationEnabled_NoSecretsStoreWarning()
/// <summary>
/// Task 11: secrets replication (the gRPC hub, and the SQL-Server ReplicatingSecretStore mode
/// that preceded it) is retired in ScadaBridge — sites receive secrets exclusively via Deploy
/// Artifacts, and a replicated site store would publish DeployedSecretsMirror's local deletes
/// fleet-wide. Checked on BOTH roles: a Site used to be the gRPC hub's follower half, so the
/// flag was never Central-only. Replaces the old warning-suppression test
/// (ClusteredCentral_ReplicationEnabled_NoSecretsStoreWarning) — that suppression is gone too,
/// because CollectWarnings never runs with this flag true on a config that also passed
/// Validate.
/// </summary>
[Theory]
[InlineData("true")]
[InlineData("True")]
public void ReplicationEnabled_IsRejected_OnCentral(string value)
{
// Review round 1, finding #1: Secrets:Replication:Enabled=true already puts this central
// node on a topology that shares state across the pair — the pre-existing gRPC hub
// (whose central-side store is the shared SQL-Server store, not per-node SQLite) or
// SQL-Server replication. No Secrets:Store:Provider is set at all here — the exact case
// that used to warn before this fix — and it must stay silent because the node is not
// actually on a per-node SQLite store in this configuration.
var values = ValidCentralConfig();
values["ScadaBridge:InboundApi:ApiKeyStore:Provider"] = "SqlServer";
values["Secrets:Replication:Enabled"] = value;
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains(
"Secrets replication (the gRPC hub) is retired in ScadaBridge", ex.Message);
Assert.Contains("sites receive secrets with Deploy Artifacts", ex.Message);
Assert.Contains("central uses Secrets:Store:Provider=SqlServer", ex.Message);
}
[Fact]
public void ReplicationEnabled_IsRejected_OnSite()
{
var values = ValidSiteConfig();
values["Secrets:Replication:Enabled"] = "true";
var config = BuildConfig(values);
var warnings = StartupValidator.CollectWarnings(config);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Empty(warnings);
Assert.Contains(
"Secrets replication (the gRPC hub) is retired in ScadaBridge", ex.Message);
}
[Fact]
public void ReplicationDisabled_PassesValidation()
{
// Explicit false must not be confused with "set" — the default posture every existing
// deployment ships stays valid.
var values = ValidCentralConfig();
values["Secrets:Replication:Enabled"] = "false";
var config = BuildConfig(values);
var ex = Record.Exception(() => StartupValidator.Validate(config));
Assert.Null(ex);
}
[Fact]