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));
}
}