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 { public CommunicationOptions CurrentValue => value; public CommunicationOptions Get(string? name) => value; public IDisposable? OnChange(Action listener) => null; } [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); } [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()); } [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); } [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); } [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)); } [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); } [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()); } [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)); } [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)); } [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)); } }