513fa1aa16
Comment/XML-doc changes only: - add summaries to ~10k test methods, fixtures, fakes and helpers (+ <param> on theories, <returns> on async Task methods, <inheritdoc/> on interface implementations the checker can resolve) - strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N, #NN, Component-0NN) from comments, keeping the explanatory text; reword false positives in comments only - real docs on fakes implementing BCL/library interfaces (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime) - reword the last 5 src/ false positives (CWE-1236, failures-1, year-0001, M365) Only non-comment edits: four one-line nested classes split across lines so members can carry docs. Build 0 errors / 0 warnings.
207 lines
9.8 KiB
C#
207 lines
9.8 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Central's per-site gRPC preshared key resolution (ClusterClient→gRPC migration, T0.3).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
public class SitePskProviderTests
|
|
{
|
|
private static SitePskProvider Create(
|
|
ISecretResolver resolver, CommunicationOptions? options = null)
|
|
=> new(
|
|
resolver,
|
|
new StaticOptionsMonitor(options ?? new CommunicationOptions()),
|
|
NullLogger<SitePskProvider>.Instance);
|
|
|
|
private sealed class StaticOptionsMonitor(CommunicationOptions value)
|
|
: IOptionsMonitor<CommunicationOptions>
|
|
{
|
|
/// <summary>Gets the fixed options instance.</summary>
|
|
public CommunicationOptions CurrentValue => value;
|
|
/// <summary>Returns the fixed options instance regardless of name.</summary>
|
|
/// <param name="name">The named-options key, ignored.</param>
|
|
/// <returns>The fixed options instance.</returns>
|
|
public CommunicationOptions Get(string? name) => value;
|
|
/// <summary>Does nothing; the fake never raises change notifications.</summary>
|
|
/// <param name="listener">The change listener, ignored.</param>
|
|
/// <returns>Always null.</returns>
|
|
public IDisposable? OnChange(Action<CommunicationOptions, string?> listener) => null;
|
|
}
|
|
|
|
/// <summary>Verifies that the key is resolved from the secret store under the site-qualified name.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task ResolvesFromTheSecretStore_UnderTheSiteQualifiedName()
|
|
{
|
|
var resolver = Substitute.For<ISecretResolver>();
|
|
resolver.GetAsync(new SecretName("SB-GRPC-PSK-site-a"), Arg.Any<CancellationToken>())
|
|
.Returns("key-for-a");
|
|
|
|
var psk = await Create(resolver).GetAsync("site-a", CancellationToken.None);
|
|
|
|
Assert.Equal("key-for-a", psk);
|
|
}
|
|
|
|
/// <summary>Verifies that a configured key map takes precedence so a host without a master key can still dial.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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<ISecretResolver>();
|
|
resolver.GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>())
|
|
.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<SecretName>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
/// <summary>Verifies that the store is still consulted for a site missing from the configured map.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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<ISecretResolver>();
|
|
resolver.GetAsync(new SecretName("SB-GRPC-PSK-site-b"), Arg.Any<CancellationToken>())
|
|
.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);
|
|
}
|
|
|
|
/// <summary>Verifies that a missing secret throws and never yields an empty key.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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<ISecretResolver>();
|
|
resolver.GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>())
|
|
.Returns((string?)null);
|
|
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
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);
|
|
}
|
|
|
|
/// <summary>Verifies that an empty secret is treated as missing.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task EmptySecret_IsTreatedAsMissing()
|
|
{
|
|
var resolver = Substitute.For<ISecretResolver>();
|
|
resolver.GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>()).Returns("");
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
async () => await Create(resolver).GetAsync("site-a", CancellationToken.None));
|
|
}
|
|
|
|
/// <summary>Verifies that a faulting store surfaces as a key-not-configured error rather than a crypto error.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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<ISecretResolver>();
|
|
resolver.GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>())
|
|
.Returns<string?>(_ => throw new InvalidOperationException("no master key configured"));
|
|
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
async () => await Create(resolver).GetAsync("site-a", CancellationToken.None));
|
|
|
|
Assert.Contains("SB-GRPC-PSK-site-a", ex.Message);
|
|
}
|
|
|
|
/// <summary>Verifies that successful resolves are cached so not every call hits the store.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task SuccessfulResolvesAreCached_SoEveryCallDoesNotHitTheStore()
|
|
{
|
|
var resolver = Substitute.For<ISecretResolver>();
|
|
resolver.GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>()).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<SecretName>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
/// <summary>Verifies that failures are not cached so a newly seeded key is picked up without a restart.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task FailuresAreNotCached_SoASeededKeyIsPickedUpWithoutARestart()
|
|
{
|
|
var resolver = Substitute.For<ISecretResolver>();
|
|
resolver.GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>())
|
|
.Returns((string?)null, "seeded-later");
|
|
var provider = Create(resolver);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
async () => await provider.GetAsync("site-a", CancellationToken.None));
|
|
|
|
Assert.Equal("seeded-later", await provider.GetAsync("site-a", CancellationToken.None));
|
|
}
|
|
|
|
/// <summary>Verifies that invalidating drops the cached key so a rotated key is read.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Invalidate_DropsTheCachedKey_SoARotatedKeyIsRead()
|
|
{
|
|
var resolver = Substitute.For<ISecretResolver>();
|
|
resolver.GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>())
|
|
.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));
|
|
}
|
|
|
|
/// <summary>Verifies that keys are scoped per site so one site never presents another site's key.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task KeysAreScopedPerSite_SoOneSiteNeverPresentsAnothersKey()
|
|
{
|
|
// The whole reason the design rejected a single fleet-wide key: blast radius.
|
|
var resolver = Substitute.For<ISecretResolver>();
|
|
resolver.GetAsync(new SecretName("SB-GRPC-PSK-site-a"), Arg.Any<CancellationToken>())
|
|
.Returns("key-a");
|
|
resolver.GetAsync(new SecretName("SB-GRPC-PSK-site-b"), Arg.Any<CancellationToken>())
|
|
.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));
|
|
}
|
|
}
|