diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/SitePskProvider.cs b/src/ZB.MOM.WW.ScadaBridge.Host/SitePskProvider.cs index 055599b4..13028b84 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/SitePskProvider.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/SitePskProvider.cs @@ -114,10 +114,9 @@ public sealed class SitePskProvider : ISitePskProvider } /// - /// Reads the key from the secrets store, treating a store fault as "not found" rather than - /// letting it propagate. A host with no master key configured (the development rig) throws - /// from the resolver; that must produce the same clear "key not configured" error as a - /// missing secret, not an opaque cryptographic one. + /// Reads the key from the secrets store. A store fault (unreachable SQL Server, no master + /// key) surfaces as , distinct from a secret + /// that is simply missing (null return), and is never cached. /// private async Task ResolveFromStoreAsync(string secretName, string siteId, CancellationToken ct) { @@ -131,10 +130,14 @@ public sealed class SitePskProvider : ISitePskProvider } catch (Exception ex) { - _logger.LogDebug(ex, - "Secret store lookup for '{SecretName}' (site {SiteId}) faulted; treating as not found.", + // A store fault is NOT "secret missing": telling the operator to seed a secret that + // exists (while the shared store is merely unreachable) sends them the wrong way. + // Surface it as a distinct exception type and keep the cause visible at Warning. + _logger.LogWarning(ex, + "Secret store lookup for '{SecretName}' (site {SiteId}) faulted; the key could not be " + + "read (this is a store fault, not a missing secret).", secretName, siteId); - return null; + throw new SecretStoreUnavailableException(secretName, siteId, ex); } } @@ -147,3 +150,29 @@ public sealed class SitePskProvider : ISitePskProvider } } } + +/// +/// The secrets store could not be read while resolving a site's gRPC preshared key — a store +/// fault, as opposed to the secret being absent (review 2026-10-21 05/U2). +/// +public sealed class SecretStoreUnavailableException : InvalidOperationException +{ + /// Initializes the exception. + /// The secret that was being read. + /// The site whose key was being resolved. + /// The underlying store fault. + public SecretStoreUnavailableException(string secretName, string siteId, Exception inner) + : base($"The secret store could not be read while resolving gRPC preshared key '{secretName}' " + + $"for site '{siteId}' ({inner.GetType().Name}: {inner.Message}); the secret may exist — " + + "this is a store fault, not a missing secret.", inner) + { + SecretName = secretName; + SiteId = siteId; + } + + /// Gets the secret that was being read. + public string SecretName { get; } + + /// Gets the site whose key was being resolved. + public string SiteId { get; } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SitePskProviderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SitePskProviderTests.cs index 5cfa66bc..661f4e5b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SitePskProviderTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SitePskProviderTests.cs @@ -122,21 +122,22 @@ public class SitePskProviderTests 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. + /// Verifies that a faulting store surfaces as a distinct store-unavailable error. /// A task that represents the asynchronous operation. [Fact] - public async Task AFaultingStore_SurfacesAsKeyNotConfigured_NotAsACryptoError() + public async Task AFaultingStore_SurfacesAsStoreUnavailable_NotAsMissingSecret() { - // 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. + // Review 2026-10-21 05/U2: a store fault must not read as "secret missing". var resolver = Substitute.For(); resolver.GetAsync(Arg.Any(), Arg.Any()) .Returns(_ => throw new InvalidOperationException("no master key configured")); - var ex = await Assert.ThrowsAsync( + var ex = await Assert.ThrowsAsync( async () => await Create(resolver).GetAsync("site-a", CancellationToken.None)); Assert.Contains("SB-GRPC-PSK-site-a", ex.Message); + Assert.Contains("store fault", ex.Message); + Assert.IsType(ex.InnerException); } /// Verifies that successful resolves are cached so not every call hits the store.