68f812eaa4
Pin all five ZB.MOM.WW.Secrets* packages 0.4.1 -> 0.5.0, which brings SecretsGrpcHubClientOptions.FallbackEndpoints and the package's internal FailoverSecretsHubReader. A site whose GrpcHub section lists fallback endpoints now fails a sweep over to the next central instead of stalling on a downed primary - safe ONLY because both central nodes serve one shared SQL secret store (scadaproj#4), so either hub answers with the same manifest; the appsettings comments say so and warn against listing endpoints backed by independent stores. appsettings.json gains "FallbackEndpoints": [] with a _fallbackEndpoints comment, and the _endpoint note's single-endpoint-stall caveat is scoped to the empty-list case it now only applies to. Wiring pins (red first on 0.4.1): site + Grpc + one fallback resolves ISecretsHubReader to FailoverSecretsHubReader with the "zb-secrets-grpc-hub:fallback:0" keyed channel present; zero fallbacks keeps the plain GrpcSecretsHubClient and no fallback channel - the pre-0.5.0 container shape byte-identical. Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
703 lines
31 KiB
C#
703 lines
31 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// DI-resolution tests for <see cref="SecretsRegistration.AddScadaBridgeSecrets"/>.
|
|
/// <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
|
|
/// 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.
|
|
/// </para>
|
|
/// <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.
|
|
/// </para>
|
|
/// </summary>
|
|
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";
|
|
|
|
/// <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?>
|
|
{
|
|
["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);
|
|
}
|
|
|
|
/// <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>
|
|
/// 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.
|
|
/// </summary>
|
|
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";
|
|
|
|
/// <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.
|
|
// ---------------------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void ReplicationDisabledByDefault_ResolvesPlainSqliteStore()
|
|
{
|
|
using ServiceProvider provider = BuildProvider(BuildConfig());
|
|
|
|
var store = provider.GetRequiredService<ISecretStore>();
|
|
|
|
Assert.IsType<SqliteSecretStore>(store);
|
|
Assert.IsNotType<ReplicatingSecretStore>(store);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReplicationDisabledByDefault_RegistersNoReplicatorHostedServices()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
services.AddScadaBridgeSecrets(BuildConfig(), SecretsNodeRole.Central);
|
|
|
|
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]
|
|
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);
|
|
// 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);
|
|
}
|
|
|
|
/// <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));
|
|
}
|
|
}
|