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.