using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NSubstitute; using ZB.MOM.WW.ScadaBridge.Communication; using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// /// Central's per-site gRPC preshared key resolution (ClusterClient→gRPC migration, T0.3). /// /// /// The property that matters most here is the negative one: a site whose key cannot be found /// must produce a throw, never an unauthenticated channel. Everything else — the two sources, /// the caching, the invalidation — exists to make that behaviour usable in practice. /// public class SitePskProviderTests { private static SitePskProvider Create( ISecretResolver resolver, CommunicationOptions? options = null) => new( resolver, new StaticOptionsMonitor(options ?? new CommunicationOptions()), NullLogger.Instance); private sealed class StaticOptionsMonitor(CommunicationOptions value) : IOptionsMonitor { /// Gets the fixed options instance. public CommunicationOptions CurrentValue => value; /// Returns the fixed options instance regardless of name. /// The named-options key, ignored. /// The fixed options instance. public CommunicationOptions Get(string? name) => value; /// Does nothing; the fake never raises change notifications. /// The change listener, ignored. /// Always null. public IDisposable? OnChange(Action listener) => null; } /// Verifies that the key is resolved from the secret store under the site-qualified name. /// A task that represents the asynchronous operation. [Fact] public async Task ResolvesFromTheSecretStore_UnderTheSiteQualifiedName() { var resolver = Substitute.For(); resolver.GetAsync(new SecretName("SB-GRPC-PSK-site-a"), Arg.Any()) .Returns("key-for-a"); var psk = await Create(resolver).GetAsync("site-a", CancellationToken.None); Assert.Equal("key-for-a", psk); } /// Verifies that a configured key map takes precedence so a host without a master key can still dial. /// A task that represents the asynchronous operation. [Fact] public async Task ConfiguredMapWins_SoAHostWithNoMasterKeyCanStillDial() { // The development rig runs with no secrets master key at all — every credential // arrives as an environment override. Without this source, the rig could not use the // gated control plane and the fail-closed design would be untestable there. var resolver = Substitute.For(); resolver.GetAsync(Arg.Any(), Arg.Any()) .Returns("from-the-store"); var options = new CommunicationOptions(); options.SitePsks["site-a"] = "from-config"; var psk = await Create(resolver, options).GetAsync("site-a", CancellationToken.None); Assert.Equal("from-config", psk); await resolver.DidNotReceive().GetAsync(Arg.Any(), Arg.Any()); } /// Verifies that the store is still consulted for a site missing from the configured map. /// A task that represents the asynchronous operation. [Fact] public async Task StoreIsStillConsulted_ForASiteMissingFromTheMap() { // Sites are added at runtime from the Central UI, so the map can never be complete. var resolver = Substitute.For(); resolver.GetAsync(new SecretName("SB-GRPC-PSK-site-b"), Arg.Any()) .Returns("key-for-b"); var options = new CommunicationOptions(); options.SitePsks["site-a"] = "from-config"; var psk = await Create(resolver, options).GetAsync("site-b", CancellationToken.None); Assert.Equal("key-for-b", psk); } /// Verifies that a missing secret throws and never yields an empty key. /// A task that represents the asynchronous operation. [Fact] public async Task MissingSecret_Throws_AndNeverYieldsAnEmptyKey() { // Fail-closed. The alternative — returning "" — would build a channel that presents // "Bearer " and gets PermissionDenied anyway, but with a far less diagnosable error. var resolver = Substitute.For(); resolver.GetAsync(Arg.Any(), Arg.Any()) .Returns((string?)null); var ex = await Assert.ThrowsAsync( async () => await Create(resolver).GetAsync("site-a", CancellationToken.None)); // The message must name both sources — either one fixes it. Assert.Contains("SB-GRPC-PSK-site-a", ex.Message); Assert.Contains("SitePsks", ex.Message); } /// Verifies that an empty secret is treated as missing. /// A task that represents the asynchronous operation. [Fact] public async Task EmptySecret_IsTreatedAsMissing() { var resolver = Substitute.For(); resolver.GetAsync(Arg.Any(), Arg.Any()).Returns(""); await Assert.ThrowsAsync( async () => await Create(resolver).GetAsync("site-a", CancellationToken.None)); } /// Verifies that a faulting store surfaces as a key-not-configured error rather than a crypto error. /// A task that represents the asynchronous operation. [Fact] public async Task AFaultingStore_SurfacesAsKeyNotConfigured_NotAsACryptoError() { // A host with no master key throws from the resolver. The operator's problem is the // same either way — "this site has no key" — so the diagnosis must say that. var resolver = Substitute.For(); resolver.GetAsync(Arg.Any(), Arg.Any()) .Returns(_ => throw new InvalidOperationException("no master key configured")); var ex = await Assert.ThrowsAsync( async () => await Create(resolver).GetAsync("site-a", CancellationToken.None)); Assert.Contains("SB-GRPC-PSK-site-a", ex.Message); } /// Verifies that successful resolves are cached so not every call hits the store. /// A task that represents the asynchronous operation. [Fact] public async Task SuccessfulResolvesAreCached_SoEveryCallDoesNotHitTheStore() { var resolver = Substitute.For(); resolver.GetAsync(Arg.Any(), Arg.Any()).Returns("k"); var provider = Create(resolver); await provider.GetAsync("site-a", CancellationToken.None); await provider.GetAsync("site-a", CancellationToken.None); await provider.GetAsync("site-a", CancellationToken.None); await resolver.Received(1).GetAsync(Arg.Any(), Arg.Any()); } /// Verifies that failures are not cached so a newly seeded key is picked up without a restart. /// A task that represents the asynchronous operation. [Fact] public async Task FailuresAreNotCached_SoASeededKeyIsPickedUpWithoutARestart() { var resolver = Substitute.For(); resolver.GetAsync(Arg.Any(), Arg.Any()) .Returns((string?)null, "seeded-later"); var provider = Create(resolver); await Assert.ThrowsAsync( async () => await provider.GetAsync("site-a", CancellationToken.None)); Assert.Equal("seeded-later", await provider.GetAsync("site-a", CancellationToken.None)); } /// Verifies that invalidating drops the cached key so a rotated key is read. /// A task that represents the asynchronous operation. [Fact] public async Task Invalidate_DropsTheCachedKey_SoARotatedKeyIsRead() { var resolver = Substitute.For(); resolver.GetAsync(Arg.Any(), Arg.Any()) .Returns("old", "rotated"); var provider = Create(resolver); Assert.Equal("old", await provider.GetAsync("site-a", CancellationToken.None)); provider.Invalidate("site-a"); Assert.Equal("rotated", await provider.GetAsync("site-a", CancellationToken.None)); } /// Verifies that keys are scoped per site so one site never presents another site's key. /// A task that represents the asynchronous operation. [Fact] public async Task KeysAreScopedPerSite_SoOneSiteNeverPresentsAnothersKey() { // The whole reason the design rejected a single fleet-wide key: blast radius. var resolver = Substitute.For(); resolver.GetAsync(new SecretName("SB-GRPC-PSK-site-a"), Arg.Any()) .Returns("key-a"); resolver.GetAsync(new SecretName("SB-GRPC-PSK-site-b"), Arg.Any()) .Returns("key-b"); var provider = Create(resolver); Assert.Equal("key-a", await provider.GetAsync("site-a", CancellationToken.None)); Assert.Equal("key-b", await provider.GetAsync("site-b", CancellationToken.None)); } }