diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/CompositeAuditWriter.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/CompositeAuditWriter.cs
index 6cb271d..923dade 100644
--- a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/CompositeAuditWriter.cs
+++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/CompositeAuditWriter.cs
@@ -1,8 +1,9 @@
namespace ZB.MOM.WW.Audit;
/// Fans an event out to several writers. Best-effort: a failing writer does not stop the others.
-/// A failing writer's exception is swallowed so the fan-out drains and the caller is never
-/// aborted — but is re-thrown so cancellation is honored.
+/// Every inner-writer failure is swallowed — including
+/// — so the fan-out drains and the caller is never aborted, honoring the
+/// "must not throw to the caller" contract even when a request-scoped cancellation token is passed.
public sealed class CompositeAuditWriter : IAuditWriter
{
private readonly IReadOnlyList _inner;
@@ -21,8 +22,7 @@ public sealed class CompositeAuditWriter : IAuditWriter
foreach (var writer in _inner)
{
try { await writer.WriteAsync(evt, ct).ConfigureAwait(false); }
- catch (OperationCanceledException) { throw; } // honor cancellation; do not swallow
- catch { /* best-effort seam: a failing writer must not stop the others or the caller */ }
+ catch { /* best-effort seam: a failing writer (incl. cancellation) must not stop the others or the caller */ }
}
}
}
diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactor.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactor.cs
index 4ca6cbd..38d8678 100644
--- a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactor.cs
+++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactor.cs
@@ -2,8 +2,9 @@ namespace ZB.MOM.WW.Audit;
///
/// Redactor that caps oversized and .
-/// Never throws — over-redacts (drops DetailsJson) on internal failure. The secret-field policy
-/// (which fields are sensitive) stays per-project; compose this with a project redactor as needed.
+/// Never throws — over-redacts (drops both DetailsJson and Target) on internal failure. The
+/// secret-field policy (which fields are sensitive) stays per-project; compose this with a project
+/// redactor as needed.
///
public sealed class TruncatingAuditRedactor : IAuditRedactor
{
@@ -26,13 +27,15 @@ public sealed class TruncatingAuditRedactor : IAuditRedactor
}
catch
{
- // Hard contract: never throw. Over-redact on internal failure.
- return rawEvent with { DetailsJson = null };
+ // Hard contract: never throw, and over-redact to a STRICTLY safer event on internal
+ // failure — scrub every field this redactor owns (both DetailsJson and Target).
+ return rawEvent with { DetailsJson = null, Target = null };
}
}
private string? Truncate(string? value, int max)
{
+ if (max < 0) max = 0; // clamp nonsensical negative caps so a config bug fails safe, not throws
if (value is null || value.Length <= max) return value;
var marker = _options.TruncationMarker;
if (marker.Length >= max) return marker[..max];
diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactorOptions.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactorOptions.cs
index 0c44aba..d00bc9b 100644
--- a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactorOptions.cs
+++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactorOptions.cs
@@ -1,12 +1,12 @@
namespace ZB.MOM.WW.Audit;
-/// Caps for .
-public sealed class TruncatingAuditRedactorOptions
+/// Immutable caps for .
+public sealed record TruncatingAuditRedactorOptions
{
/// Max length of before truncation. Default 4096.
- public int MaxDetailsJsonLength { get; set; } = 4096;
+ public int MaxDetailsJsonLength { get; init; } = 4096;
/// Max length of before truncation. Default 512.
- public int MaxTargetLength { get; set; } = 512;
+ public int MaxTargetLength { get; init; } = 512;
/// Marker appended to a truncated value. Default "…[truncated]".
- public string TruncationMarker { get; set; } = "…[truncated]";
+ public string TruncationMarker { get; init; } = "…[truncated]";
}
diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/ZB.MOM.WW.Audit.csproj b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/ZB.MOM.WW.Audit.csproj
index 7951fde..22a7283 100644
--- a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/ZB.MOM.WW.Audit.csproj
+++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/ZB.MOM.WW.Audit.csproj
@@ -3,6 +3,8 @@
net10.0enableenable
+
+ truetrue
diff --git a/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/CompositeAuditWriterTests.cs b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/CompositeAuditWriterTests.cs
index 1faef9c..4b82b0d 100644
--- a/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/CompositeAuditWriterTests.cs
+++ b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/CompositeAuditWriterTests.cs
@@ -38,11 +38,32 @@ public class CompositeAuditWriterTests
}
[Fact]
- public async Task Cancellation_is_propagated_not_swallowed()
+ public async Task Cancellation_does_not_surface_to_the_caller()
{
- // OperationCanceledException is re-thrown (unlike ordinary writer failures, which are swallowed).
+ // Per the IAuditWriter hard contract ("must not throw to the caller"), an
+ // OperationCanceledException from an inner writer is swallowed like any other failure —
+ // it must NOT abort the user-facing action that produced the event.
var after = new RecordingWriter();
var sut = new CompositeAuditWriter(new IAuditWriter[] { new CancellingWriter(), after });
- await Assert.ThrowsAsync(() => sut.WriteAsync(Evt()));
+ await sut.WriteAsync(Evt()); // must not throw
+ Assert.Equal(1, after.Count); // drain continues past the cancelled writer
+ }
+
+ [Fact]
+ public async Task Empty_writer_list_is_a_no_op()
+ {
+ var sut = new CompositeAuditWriter(Array.Empty());
+ await sut.WriteAsync(Evt()); // must not throw
+ }
+
+ [Fact]
+ public async Task Null_writer_entry_is_swallowed_and_does_not_stop_the_others()
+ {
+ // A null inner writer faults the await; the best-effort seam swallows it (like any
+ // other writer failure) and continues draining the remaining writers.
+ var after = new RecordingWriter();
+ var sut = new CompositeAuditWriter(new IAuditWriter?[] { null, after }!);
+ await sut.WriteAsync(Evt()); // must not throw
+ Assert.Equal(1, after.Count);
}
}
diff --git a/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/TruncatingAuditRedactorTests.cs b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/TruncatingAuditRedactorTests.cs
index 02ba387..15b6cb7 100644
--- a/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/TruncatingAuditRedactorTests.cs
+++ b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/TruncatingAuditRedactorTests.cs
@@ -53,4 +53,30 @@ public class TruncatingAuditRedactorTests
var result = r.Apply(Evt(new string('x', 20)));
Assert.Equal(3, result.DetailsJson!.Length);
}
+
+ [Fact]
+ public void Negative_max_is_treated_as_zero_and_does_not_throw()
+ {
+ // A negative cap is nonsensical misconfiguration. Truncate must clamp to 0 rather than
+ // throw, capping the value to the empty string (plus marker handling).
+ var opts = new TruncatingAuditRedactorOptions { MaxDetailsJsonLength = -5, MaxTargetLength = -1, TruncationMarker = "" };
+ var r = new TruncatingAuditRedactor(opts);
+ var result = r.Apply(Evt(new string('x', 20), target: new string('y', 20)));
+ Assert.Equal(string.Empty, result.DetailsJson);
+ Assert.Equal(string.Empty, result.Target);
+ }
+
+ [Fact]
+ public void Over_redact_fallback_scrubs_both_details_and_target_without_throwing()
+ {
+ // Drive the REAL TruncatingAuditRedactor.Apply into its catch branch via a reachable
+ // misconfiguration (a null TruncationMarker faults inside Truncate). The over-redact
+ // fallback must be strictly safer: BOTH DetailsJson AND Target scrubbed to null, no throw.
+ var opts = new TruncatingAuditRedactorOptions { MaxDetailsJsonLength = 5, TruncationMarker = null! };
+ var r = new TruncatingAuditRedactor(opts);
+ var raw = Evt(new string('x', 50), target: "sensitive target");
+ var result = r.Apply(raw);
+ Assert.Null(result.DetailsJson);
+ Assert.Null(result.Target);
+ }
}
diff --git a/ZB.MOM.WW.Auth/README.md b/ZB.MOM.WW.Auth/README.md
index a5c22d1..bab3786 100644
--- a/ZB.MOM.WW.Auth/README.md
+++ b/ZB.MOM.WW.Auth/README.md
@@ -10,8 +10,8 @@ Authentication and authorisation libraries for the **ZB.MOM.WW SCADA family** (O
|---|---|---|
| `ZB.MOM.WW.Auth.Abstractions` | Auth contracts, canonical role constants, and shared types (`LdapOptions`, `LdapAuthResult`, `ILdapAuthService`, `IApiKeyStore`). No runtime dependencies beyond the BCL. | — |
| `ZB.MOM.WW.Auth.Ldap` | LDAP authentication service: bind-then-search-then-bind against GLAuth or Active Directory; RFC 4514-aware group extraction; fail-closed. | `Abstractions`, `Novell.Directory.Ldap.NETStandard` |
-| `ZB.MOM.WW.Auth.ApiKeys` | SQLite-backed API-key store with pepper-based PBKDF2 hashing, rotation, and audit log. Includes a `MigrationHostedService` that runs schema migrations on startup. | `Abstractions`, `Microsoft.Data.Sqlite` |
-| `ZB.MOM.WW.Auth.AspNetCore` | ASP.NET Core DI helpers (`AddZbAuth`), cookie defaults, claim-type constants, and `LdapOptionsValidator` registration. Wires together Ldap + ApiKeys + cookie middleware. | `Abstractions`, `Ldap`, `ApiKeys`, `Microsoft.AspNetCore.App` |
+| `ZB.MOM.WW.Auth.ApiKeys` | SQLite-backed API-key store with **pepper-keyed HMAC-SHA256** secret hashing, rotation, and audit log. DI wiring is `AddZbApiKeyAuth`; an opt-in `MigrationHostedService` runs schema migrations on startup. | `Abstractions`, `Microsoft.Data.Sqlite` |
+| `ZB.MOM.WW.Auth.AspNetCore` | ASP.NET Core wiring for the **LDAP** provider only: `AddZbLdapAuth` (binds + start-time-validates `LdapOptions`, registers `ILdapAuthService`), plus `ZbCookieDefaults.Apply` (hardened cookie helper the consumer calls itself) and `ZbClaimTypes` constants. It does **not** wire API keys or cookie middleware — API-key DI is `AddZbApiKeyAuth` in the `ApiKeys` package. | `Abstractions`, `Ldap`, `Microsoft.AspNetCore.App` |
---
diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/Ldap/LdapContracts.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/Ldap/LdapContracts.cs
index c442220..bc89047 100644
--- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/Ldap/LdapContracts.cs
+++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/Ldap/LdapContracts.cs
@@ -1,3 +1,5 @@
+using System.Net.Security;
+
namespace ZB.MOM.WW.Auth.Abstractions.Ldap;
public enum LdapTransport { Ldaps, StartTls, None }
@@ -16,6 +18,16 @@ public sealed record LdapOptions
public string DisplayNameAttribute { get; init; } = "cn";
public string GroupAttribute { get; init; } = "memberOf";
public int ConnectionTimeoutMs { get; init; } = 10_000;
+
+ ///
+ /// Optional hook to harden (or, in dev, relax) TLS server-certificate validation for the
+ /// / transports. When
+ /// (the default) the LDAP client validates the server certificate against
+ /// the OS trust store — it does not blind-accept. Supply a callback to pin a CA, validate
+ /// the SAN against , or otherwise tighten validation. This is a code-only seam
+ /// (not bound from configuration) and takes precedence over .
+ ///
+ public RemoteCertificateValidationCallback? ServerCertificateValidationCallback { get; init; }
}
public enum LdapAuthFailure { BadCredentials, UserNotFound, AmbiguousUser, GroupLookupFailed, ServiceAccountBindFailed, Disabled }
diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Admin/ApiKeyAdminCommands.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Admin/ApiKeyAdminCommands.cs
index cb24bfc..5aeab04 100644
--- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Admin/ApiKeyAdminCommands.cs
+++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Admin/ApiKeyAdminCommands.cs
@@ -101,7 +101,10 @@ public sealed class ApiKeyAdminCommands
var record = new ApiKeyRecord(
KeyId: keyId,
- KeyPrefix: $"{_options.TokenPrefix}_{keyId}",
+ // KeyPrefix is the bare token prefix (e.g. "mxgw"), NOT prefix_keyId — the key id is
+ // already its own column. Embedding it here produced a self-referential value that
+ // confused admin tooling and disagreed with the read/test paths (see Auth-005).
+ KeyPrefix: _options.TokenPrefix,
SecretHash: secretHash,
DisplayName: displayName,
Scopes: scopes,
diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ApiKeyVerifier.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ApiKeyVerifier.cs
index e0cb1d9..7ae5da6 100644
--- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ApiKeyVerifier.cs
+++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ApiKeyVerifier.cs
@@ -62,8 +62,24 @@ public sealed class ApiKeyVerifier(
return Fail(ApiKeyFailure.SecretMismatch);
}
- // 6. Record successful use, then return the identity (no secret/hash/pepper included).
- await store.MarkUsedAsync(record.KeyId, _timeProvider.GetUtcNow(), ct).ConfigureAwait(false);
+ // 6. The authentication decision is already made (line 60). Recording last-used is
+ // best-effort bookkeeping: a transient storage hiccup (SQLITE_BUSY past the busy-timeout,
+ // disk full, DB locked by a migration) must NOT turn an otherwise-valid credential into a
+ // failed auth. Swallow any non-cancellation failure so the only exception path remains
+ // cancellation, as the class contract promises. Cancellation is honoured (re-thrown).
+ try
+ {
+ await store.MarkUsedAsync(record.KeyId, _timeProvider.GetUtcNow(), ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch
+ {
+ // Best-effort: the last-used write failed, but the credential is valid. Fail open on the
+ // bookkeeping (not the auth decision) rather than denying a legitimate caller.
+ }
return new ApiKeyVerification(
Succeeded: true,
diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/ScopeSerializer.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/ScopeSerializer.cs
index 801842d..8c1cbcf 100644
--- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/ScopeSerializer.cs
+++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/ScopeSerializer.cs
@@ -20,7 +20,12 @@ public static class ScopeSerializer
/// Deserializes scopes from a JSON array string.
/// The JSON string to deserialize; may be null or empty.
- /// An ordinal-compared set of scopes; empty when the input is null/blank.
+ ///
+ /// An ordinal-compared set of scopes; empty when the input is null/blank. A malformed or
+ /// non-array column (operator tampering, a partial write, a format change, or a buggy writer)
+ /// fails closed to an EMPTY set rather than throwing, so a single poisoned row degrades to a
+ /// zero-scope identity on the auth path instead of an unhandled .
+ ///
public static IReadOnlySet Deserialize(string? value)
{
if (string.IsNullOrWhiteSpace(value))
@@ -28,7 +33,18 @@ public static class ScopeSerializer
return new HashSet(StringComparer.Ordinal);
}
- string[]? scopes = JsonSerializer.Deserialize(value);
+ string[]? scopes;
+ try
+ {
+ scopes = JsonSerializer.Deserialize(value);
+ }
+ catch (JsonException)
+ {
+ // Fail closed: a corrupt scopes column yields no scopes rather than an exception on the
+ // verification hot path. The verifier's "only exception path is cancellation" contract
+ // is preserved, and a key with an unreadable scope set is left with zero authority.
+ return new HashSet(StringComparer.Ordinal);
+ }
return new HashSet(scopes ?? [], StringComparer.Ordinal);
}
diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.AspNetCore/ServiceCollectionExtensions.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.AspNetCore/ServiceCollectionExtensions.cs
index 3cabb80..e899e47 100644
--- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.AspNetCore/ServiceCollectionExtensions.cs
+++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.AspNetCore/ServiceCollectionExtensions.cs
@@ -37,7 +37,14 @@ public static class ServiceCollectionExtensions
ArgumentNullException.ThrowIfNull(config);
ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath);
- services.Configure(config.GetSection(sectionPath));
+ // Bind via the options builder and opt into start-time validation. An IValidateOptions
+ // otherwise only runs when the options are first materialized (IOptions.Value) — which
+ // here is the first login (ILdapAuthService factory below), not boot. ValidateOnStart hooks
+ // the host's start-time options validation so a misconfigured directory (e.g. insecure
+ // transport without AllowInsecure) fails fast at startup rather than on first login.
+ services.AddOptions()
+ .Bind(config.GetSection(sectionPath))
+ .ValidateOnStart();
// Fail fast at startup on a misconfigured directory rather than on first login.
services.AddSingleton, LdapOptionsValidator>();
diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/ILdapConnection.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/ILdapConnection.cs
index 79a8e88..67f3855 100644
--- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/ILdapConnection.cs
+++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/ILdapConnection.cs
@@ -1,5 +1,6 @@
namespace ZB.MOM.WW.Auth.Ldap.Internal;
+using System.Net.Security;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
///
@@ -15,8 +16,29 @@ internal sealed record LdapSearchEntry(
///
internal interface ILdapConnection : IDisposable
{
- /// Opens (and optionally upgrades to TLS) a connection to the given host.
- void Connect(string host, int port, LdapTransport transport, bool allowInsecure, int timeoutMs);
+ ///
+ /// Opens (and optionally upgrades to TLS) a connection to the given host.
+ ///
+ /// The LDAP server hostname or IP.
+ /// The LDAP server port.
+ /// The transport security mode.
+ ///
+ /// When AND no is
+ /// supplied, TLS server-certificate validation is bypassed (dev/test only). Ignored when a
+ /// validation callback is supplied (the callback wins) or for plaintext transport.
+ ///
+ /// The connection/operation timeout in milliseconds.
+ ///
+ /// Optional TLS server-certificate validation callback. When , the OS trust
+ /// store is used (the client does not blind-accept).
+ ///
+ void Connect(
+ string host,
+ int port,
+ LdapTransport transport,
+ bool allowInsecure,
+ int timeoutMs,
+ RemoteCertificateValidationCallback? serverCertificateValidationCallback);
/// Binds with the supplied DN and password. Throws LdapException on bad credentials.
void Bind(string dn, string password);
diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/NovellLdapConnection.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/NovellLdapConnection.cs
index c7bc1c6..3c684a7 100644
--- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/NovellLdapConnection.cs
+++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/NovellLdapConnection.cs
@@ -2,19 +2,67 @@ namespace ZB.MOM.WW.Auth.Ldap.Internal;
using Novell.Directory.Ldap;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
+// Disambiguate: Novell also declares a RemoteCertificateValidationCallback delegate; the seam and
+// LdapConnectionOptions.ConfigureRemoteCertificateValidationCallback both use the BCL one.
+using RemoteCertificateValidationCallback = System.Net.Security.RemoteCertificateValidationCallback;
///
/// Production backed by Novell.Directory.Ldap.LdapConnection.
/// Mirrors the connection/search idioms from ZB.MOM.WW.ScadaBridge.Security.LdapAuthService.
///
+///
+/// TLS server-certificate validation: by default the underlying
+/// Novell.Directory.Ldap.NETStandard client validates the server certificate against the OS
+/// trust store (it does NOT blind-accept). A caller-supplied
+/// RemoteCertificateValidationCallback overrides that default (CA pinning / SAN checks); when
+/// none is supplied and allowInsecure is set, validation is bypassed for dev/test only.
+///
internal sealed class NovellLdapConnection : ILdapConnection
{
- private readonly LdapConnection _conn = new();
+ private readonly LdapConnection _conn;
private bool _disposed;
- ///
- public void Connect(string host, int port, LdapTransport transport, bool allowInsecure, int timeoutMs)
+ ///
+ /// Builds the connection, wiring a TLS server-certificate validation policy: a supplied
+ /// wins; otherwise
+ /// bypasses validation (dev/test only); otherwise the OS-trust-store default applies.
+ ///
+ public NovellLdapConnection(
+ bool allowInsecure = false,
+ RemoteCertificateValidationCallback? serverCertificateValidationCallback = null)
{
+ if (serverCertificateValidationCallback is not null)
+ {
+ var options = new LdapConnectionOptions()
+ .ConfigureRemoteCertificateValidationCallback(serverCertificateValidationCallback);
+ _conn = new LdapConnection(options);
+ }
+ else if (allowInsecure)
+ {
+ // Dev/test only: accept any server certificate. Reachable solely when an operator has set
+ // AllowInsecure (rejected for plaintext-without-AllowInsecure by LdapOptionsValidator).
+ var options = new LdapConnectionOptions()
+ .ConfigureRemoteCertificateValidationCallback((_, _, _, _) => true);
+ _conn = new LdapConnection(options);
+ }
+ else
+ {
+ // Default: validate against the OS trust store (no blind-accept).
+ _conn = new LdapConnection();
+ }
+ }
+
+ ///
+ public void Connect(
+ string host,
+ int port,
+ LdapTransport transport,
+ bool allowInsecure,
+ int timeoutMs,
+ RemoteCertificateValidationCallback? serverCertificateValidationCallback)
+ {
+ // The TLS-validation policy (allowInsecure / callback) is wired at construction time on the
+ // LdapConnectionOptions; the per-call arguments here are accepted for seam symmetry.
ApplyTimeout(timeoutMs);
// LDAPS: TLS is negotiated at the TCP-connection level.
@@ -98,8 +146,16 @@ internal sealed class NovellLdapConnection : ILdapConnection
}
}
-/// Factory that produces fresh instances.
-internal sealed class NovellLdapConnectionFactory : ILdapConnectionFactory
+///
+/// Factory that produces fresh instances, carrying the TLS
+/// server-certificate validation policy (a supplied callback, or an allowInsecure bypass) so
+/// it is wired onto each connection at construction time.
+///
+internal sealed class NovellLdapConnectionFactory(
+ bool allowInsecure = false,
+ RemoteCertificateValidationCallback? serverCertificateValidationCallback = null)
+ : ILdapConnectionFactory
{
- public ILdapConnection Create() => new NovellLdapConnection();
+ public ILdapConnection Create() =>
+ new NovellLdapConnection(allowInsecure, serverCertificateValidationCallback);
}
diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs
index 53ca248..892c173 100644
--- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs
+++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs
@@ -26,10 +26,14 @@ public sealed class LdapAuthService : ILdapAuthService
///
/// Production constructor: binds against a live directory via the real
- /// Novell-backed connection factory.
+ /// Novell-backed connection factory. The TLS server-certificate validation policy
+ /// ( or the
+ /// bypass) is carried into the factory so each
+ /// connection is built with it.
///
public LdapAuthService(LdapOptions options)
- : this(options, new NovellLdapConnectionFactory())
+ : this(options, new NovellLdapConnectionFactory(
+ options.AllowInsecure, options.ServerCertificateValidationCallback))
{
}
@@ -92,7 +96,13 @@ public sealed class LdapAuthService : ILdapAuthService
// Abstractions change could add DirectoryUnavailable to disambiguate.
try
{
- conn.Connect(_options.Server, _options.Port, _options.Transport, _options.AllowInsecure, _options.ConnectionTimeoutMs);
+ conn.Connect(
+ _options.Server,
+ _options.Port,
+ _options.Transport,
+ _options.AllowInsecure,
+ _options.ConnectionTimeoutMs,
+ _options.ServerCertificateValidationCallback);
}
catch (LdapException)
{
diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyAdminCommandsTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyAdminCommandsTests.cs
index f7aaf44..6e00ba4 100644
--- a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyAdminCommandsTests.cs
+++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyAdminCommandsTests.cs
@@ -87,6 +87,33 @@ public sealed class ApiKeyAdminCommandsTests : IAsyncLifetime
Assert.Single(recent, e => e.EventType == "create-key");
}
+ [Fact]
+ public async Task CreateKey_PersistsBareTokenPrefix_NotPrefixUnderscoreKeyId()
+ {
+ // Auth-005: KeyPrefix is the bare token prefix ("mxgw"), NOT "mxgw_key-1". The key id is
+ // already its own column; embedding it produced a self-referential value that disagreed with
+ // the read/test paths and confused admin tooling.
+ ApiKeyAdminCommands commands = BuildCommands();
+ await commands.InitDbAsync(null, CancellationToken.None);
+
+ await commands.CreateKeyAsync(
+ "key-1",
+ "Service A",
+ new HashSet(["read"], StringComparer.Ordinal),
+ constraintsJson: null,
+ remoteAddress: null,
+ CancellationToken.None);
+
+ ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
+ Assert.NotNull(found);
+ Assert.Equal("mxgw", found!.KeyPrefix);
+
+ // The same bare prefix is surfaced by the admin list projection.
+ IReadOnlyList listed = await commands.ListKeysAsync(CancellationToken.None);
+ ApiKeyListItem item = Assert.Single(listed, k => k.KeyId == "key-1");
+ Assert.Equal("mxgw", item.KeyPrefix);
+ }
+
[Fact]
public async Task CreateKey_PepperUnavailable_ReturnsNoTokenAndAppendsNoAudit()
{
diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyVerifierTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyVerifierTests.cs
index 242d618..6195fa1 100644
--- a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyVerifierTests.cs
+++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyVerifierTests.cs
@@ -212,6 +212,51 @@ public class ApiKeyVerifierTests
Assert.DoesNotContain(Convert.ToBase64String(hash), identityText, StringComparison.Ordinal);
}
+ // --- Auth-002: a failed best-effort MarkUsedAsync must NOT fail a valid key ---
+
+ [Fact]
+ public async Task VerifyAsync_ValidKey_MarkUsedThrows_StillSucceeds()
+ {
+ // MarkUsedAsync is best-effort "last used" bookkeeping. A transient storage failure
+ // (SQLITE_BUSY, disk full, locked DB) must not turn an otherwise-valid credential into a
+ // failed auth: the decision is already made before the usage write. The verifier's contract
+ // is "the only exception path is cancellation", so a non-cancellation MarkUsedAsync failure
+ // is swallowed and the result is still Succeeded == true.
+ byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
+ var store = new FakeApiKeyStore
+ {
+ Record = BuildRecord(hash),
+ MarkUsedException = new InvalidOperationException("SQLITE_BUSY"),
+ };
+ var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
+
+ ApiKeyVerification result =
+ await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
+
+ Assert.True(result.Succeeded);
+ Assert.Null(result.Failure);
+ Assert.NotNull(result.Identity);
+ Assert.Equal(KeyId, result.Identity!.KeyId);
+ Assert.True(store.MarkUsedCalled);
+ }
+
+ [Fact]
+ public async Task VerifyAsync_MarkUsedThrowsOperationCanceled_Propagates()
+ {
+ // The ONLY exception path is cancellation: an OperationCanceledException from the usage
+ // write (e.g. the request was cancelled mid-write) is honoured and re-thrown, not swallowed.
+ byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
+ var store = new FakeApiKeyStore
+ {
+ Record = BuildRecord(hash),
+ MarkUsedException = new OperationCanceledException(),
+ };
+ var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
+
+ await Assert.ThrowsAnyAsync(
+ () => verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None));
+ }
+
// --- Cancellation ---
[Fact]
@@ -253,6 +298,9 @@ public class ApiKeyVerifierTests
public string? MarkUsedKeyId { get; private set; }
public DateTimeOffset? MarkUsedWhenUtc { get; private set; }
+ /// When set, throws this exception (after recording the call).
+ public Exception? MarkUsedException { get; set; }
+
public Task FindByKeyIdAsync(string keyId, CancellationToken ct)
{
FindByKeyIdCalled = true;
@@ -267,6 +315,11 @@ public class ApiKeyVerifierTests
MarkUsedCalled = true;
MarkUsedKeyId = keyId;
MarkUsedWhenUtc = whenUtc;
+ if (MarkUsedException is not null)
+ {
+ return Task.FromException(MarkUsedException);
+ }
+
return Task.CompletedTask;
}
}
diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteApiKeyStoreTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteApiKeyStoreTests.cs
index bf98278..81335a6 100644
--- a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteApiKeyStoreTests.cs
+++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteApiKeyStoreTests.cs
@@ -164,6 +164,36 @@ public sealed class SqliteApiKeyStoreTests : IAsyncLifetime
Assert.Empty(ScopeSerializer.Deserialize(""));
}
+ // --- Auth-003: corrupt scopes JSON must fail closed (empty set), never throw JsonException ---
+
+ [Theory]
+ [InlineData("not json at all")]
+ [InlineData("{")]
+ [InlineData("{\"a\":1}")] // valid JSON, but an object, not a string[]
+ [InlineData("42")] // valid JSON, but a number
+ [InlineData("[\"read\",")] // truncated/partial write
+ public void ScopeSerializer_DeserializeMalformed_ReturnsEmptySet_DoesNotThrow(string value)
+ {
+ // A poisoned scopes column (tampering, partial write, format change, buggy writer) must
+ // degrade to a zero-scope set rather than throwing on the verification hot path.
+ IReadOnlySet scopes = ScopeSerializer.Deserialize(value);
+ Assert.Empty(scopes);
+ }
+
+ [Fact]
+ public async Task FindByKeyId_CorruptScopesColumn_ReturnsRecordWithEmptyScopes_DoesNotThrow()
+ {
+ // Insert a row whose scopes column holds malformed (non-array) JSON, then read it through
+ // the store. The store must NOT propagate a JsonException out of FindByKeyIdAsync (which the
+ // verifier relies on for its "only exception path is cancellation" contract).
+ await InsertWithRawScopesAsync("key-corrupt", scopesJson: "{ this is not valid json");
+
+ ApiKeyRecord? found = await _store.FindByKeyIdAsync("key-corrupt", CancellationToken.None);
+
+ Assert.NotNull(found);
+ Assert.Empty(found!.Scopes);
+ }
+
private static ApiKeyRecord SampleRecord(string keyId) => new(
KeyId: keyId,
KeyPrefix: "mxgw_ab12",
@@ -213,6 +243,33 @@ public sealed class SqliteApiKeyStoreTests : IAsyncLifetime
await command.ExecuteNonQueryAsync(CancellationToken.None);
}
+ private async Task InsertWithRawScopesAsync(string keyId, string scopesJson)
+ {
+ // Writes the scopes column verbatim (NOT via ScopeSerializer.Serialize) so a malformed
+ // value can be persisted to simulate tampering / a partial or buggy write.
+ await using SqliteConnection connection =
+ await _factory.OpenConnectionAsync(CancellationToken.None);
+ await using SqliteCommand command = connection.CreateCommand();
+ command.CommandText = """
+ INSERT INTO api_keys (
+ key_id, key_prefix, secret_hash, display_name, scopes,
+ constraints, created_utc, last_used_utc, revoked_utc)
+ VALUES (
+ $key_id, $key_prefix, $secret_hash, $display_name, $scopes,
+ $constraints, $created_utc, $last_used_utc, $revoked_utc);
+ """;
+ command.Parameters.AddWithValue("$key_id", keyId);
+ command.Parameters.AddWithValue("$key_prefix", "mxgw");
+ command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = new byte[] { 1, 2, 3 };
+ command.Parameters.AddWithValue("$display_name", "Corrupt Key");
+ command.Parameters.AddWithValue("$scopes", scopesJson);
+ command.Parameters.AddWithValue("$constraints", DBNull.Value);
+ command.Parameters.AddWithValue("$created_utc", DateTimeOffset.UnixEpoch.ToString("O"));
+ command.Parameters.AddWithValue("$last_used_utc", DBNull.Value);
+ command.Parameters.AddWithValue("$revoked_utc", DBNull.Value);
+ await command.ExecuteNonQueryAsync(CancellationToken.None);
+ }
+
public Task DisposeAsync()
{
SqliteConnection.ClearAllPools();
diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.AspNetCore.Tests/ServiceCollectionExtensionsTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.AspNetCore.Tests/ServiceCollectionExtensionsTests.cs
index ea32612..e59fd30 100644
--- a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.AspNetCore.Tests/ServiceCollectionExtensionsTests.cs
+++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.AspNetCore.Tests/ServiceCollectionExtensionsTests.cs
@@ -1,5 +1,6 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.AspNetCore;
@@ -85,4 +86,52 @@ public class ServiceCollectionExtensionsTests
Assert.Contains(validators, v => v is LdapOptionsValidator);
}
+
+ // --- Auth-001: ValidateOnStart must run options validation at host startup, not first login ---
+
+ private static IConfiguration BuildInsecureConfiguration() =>
+ new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ [$"{LdapSection}:Server"] = LdapServer,
+ [$"{LdapSection}:SearchBase"] = "dc=example,dc=com",
+ [$"{LdapSection}:ServiceAccountDn"] = "cn=svc,dc=example,dc=com",
+ // Plaintext transport without AllowInsecure: the validator must reject this.
+ [$"{LdapSection}:Transport"] = nameof(LdapTransport.None),
+ [$"{LdapSection}:AllowInsecure"] = "false",
+ })
+ .Build();
+
+ [Fact]
+ public async Task AddZbLdapAuth_StartingHost_FailsForInsecureConfig()
+ {
+ // The misconfiguration must surface at host start, not deferred until the first login
+ // (i.e. the first ILdapAuthService resolution). ValidateOnStart wires the host's
+ // start-time options validation, so StartAsync must throw OptionsValidationException.
+ IConfiguration config = BuildInsecureConfiguration();
+
+ using IHost host = new HostBuilder()
+ .ConfigureServices(services => services.AddZbLdapAuth(config, LdapSection))
+ .Build();
+
+ OptionsValidationException ex =
+ await Assert.ThrowsAsync(() => host.StartAsync());
+
+ Assert.Contains(nameof(LdapOptions.Transport), string.Join(" ", ex.Failures));
+ }
+
+ [Fact]
+ public async Task AddZbLdapAuth_StartingHost_SucceedsForSecureConfig()
+ {
+ // A valid (secure) config must start cleanly — proving ValidateOnStart does not reject
+ // well-formed options.
+ IConfiguration config = BuildConfiguration();
+
+ using IHost host = new HostBuilder()
+ .ConfigureServices(services => services.AddZbLdapAuth(config, LdapSection))
+ .Build();
+
+ await host.StartAsync();
+ await host.StopAsync();
+ }
}
diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/FakeLdapConnection.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/FakeLdapConnection.cs
index ae9fd39..97ec79d 100644
--- a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/FakeLdapConnection.cs
+++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/FakeLdapConnection.cs
@@ -1,3 +1,4 @@
+using System.Net.Security;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Ldap.Internal;
@@ -19,6 +20,10 @@ internal sealed class FakeLdapConnection : ILdapConnection
// ---- observation -----
public (string Host, int Port, LdapTransport Transport, bool AllowInsecure, int TimeoutMs)? ConnectArgs { get; private set; }
+
+ /// The server-certificate validation callback passed to the most recent call.
+ public RemoteCertificateValidationCallback? ConnectCertCallback { get; private set; }
+
public List BoundDns { get; } = new();
///
@@ -107,9 +112,16 @@ internal sealed class FakeLdapConnection : ILdapConnection
// ---- ILdapConnection -----
- public void Connect(string host, int port, LdapTransport transport, bool allowInsecure, int timeoutMs)
+ public void Connect(
+ string host,
+ int port,
+ LdapTransport transport,
+ bool allowInsecure,
+ int timeoutMs,
+ RemoteCertificateValidationCallback? serverCertificateValidationCallback = null)
{
ConnectArgs = (host, port, transport, allowInsecure, timeoutMs);
+ ConnectCertCallback = serverCertificateValidationCallback;
if (_throwOnConnect)
throw new Novell.Directory.Ldap.LdapException(
"Directory unreachable", Novell.Directory.Ldap.LdapException.ConnectError, host);
diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceTests.cs
index f9f66d3..44ff334 100644
--- a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceTests.cs
+++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceTests.cs
@@ -1,3 +1,4 @@
+using System.Net.Security;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Ldap;
@@ -80,6 +81,56 @@ public class LdapAuthServiceTests
Assert.Equal(LdapAuthFailure.Disabled, (await svc.AuthenticateAsync("a", "b", default)).Failure);
}
+ // --- Auth-006: TLS validation seam — allowInsecure is honoured and a cert-validation
+ // callback is threaded into the connection rather than being silently ignored. ---
+
+ [Fact]
+ public async Task Connect_ReceivesAllowInsecureFlag_FromOptions()
+ {
+ // The allowInsecure flag must reach the connection (it used to be an unused parameter).
+ var fake = new FakeLdapConnection().WithUserEntry(
+ "cn=alice,dc=x", memberOf: new[] { "cn=Engineers,ou=g,dc=x" });
+ var svc = new LdapAuthService(
+ Opts() with { AllowInsecure = true }, new FakeLdapConnectionFactory(fake));
+
+ await svc.AuthenticateAsync("alice", "pw", default);
+
+ Assert.NotNull(fake.ConnectArgs);
+ Assert.True(fake.ConnectArgs!.Value.AllowInsecure);
+ }
+
+ [Fact]
+ public async Task Connect_ReceivesConfiguredCertValidationCallback()
+ {
+ // A consumer-supplied RemoteCertificateValidationCallback must be passed through to the
+ // connection so production callers can pin a CA / validate the SAN — the seam no longer
+ // discards it.
+ RemoteCertificateValidationCallback callback = (_, _, _, _) => true;
+ var fake = new FakeLdapConnection().WithUserEntry(
+ "cn=alice,dc=x", memberOf: new[] { "cn=Engineers,ou=g,dc=x" });
+ var svc = new LdapAuthService(
+ Opts() with { ServerCertificateValidationCallback = callback },
+ new FakeLdapConnectionFactory(fake));
+
+ await svc.AuthenticateAsync("alice", "pw", default);
+
+ Assert.Same(callback, fake.ConnectCertCallback);
+ }
+
+ [Fact]
+ public async Task Connect_NoCertCallbackConfigured_PassesNull()
+ {
+ // Default: no callback configured -> null reaches the connection, which means the
+ // production adapter falls back to OS-trust-store validation (documented behaviour).
+ var fake = new FakeLdapConnection().WithUserEntry(
+ "cn=alice,dc=x", memberOf: new[] { "cn=Engineers,ou=g,dc=x" });
+ var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
+
+ await svc.AuthenticateAsync("alice", "pw", default);
+
+ Assert.Null(fake.ConnectCertCallback);
+ }
+
[Fact]
public async Task PreservesEscapedCommaInGroupName_OnRfc4514Dn()
{
diff --git a/ZB.MOM.WW.Configuration/Directory.Build.props b/ZB.MOM.WW.Configuration/Directory.Build.props
index b234d68..520df21 100644
--- a/ZB.MOM.WW.Configuration/Directory.Build.props
+++ b/ZB.MOM.WW.Configuration/Directory.Build.props
@@ -6,5 +6,6 @@
latest0.1.0true
+ true
diff --git a/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/Checks.cs b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/Checks.cs
index f9c1e6f..e81eb6b 100644
--- a/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/Checks.cs
+++ b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/Checks.cs
@@ -1,3 +1,5 @@
+using System.Globalization;
+
namespace ZB.MOM.WW.Configuration;
///
@@ -16,11 +18,14 @@ internal static class Checks
///
/// Validates a raw string as a TCP port (parse + range), returning null when valid.
- /// Centralizes the port wording for callers that hold the raw config value.
+ /// Centralizes the port wording for callers that hold the raw config value. Parsing is strict
+ /// and culture-invariant (): a leading sign or surrounding
+ /// whitespace is rejected. Both the parse-failure and range-failure messages quote the offending
+ /// raw value so they read consistently.
///
internal static string? PortValue(string? raw, string field) =>
- int.TryParse(raw, out var port)
- ? Port(port, field)
+ int.TryParse(raw, NumberStyles.None, CultureInfo.InvariantCulture, out var port) && port is >= 1 and <= 65535
+ ? null
: $"{field} must be between 1 and 65535 (was '{raw ?? "null"}')";
///
@@ -33,7 +38,7 @@ internal static class Checks
var idx = value.LastIndexOf(':');
if (idx <= 0 || idx == value.Length - 1
|| value.AsSpan(0, idx).Contains(':')
- || !int.TryParse(value[(idx + 1)..], out var port)
+ || !int.TryParse(value[(idx + 1)..], NumberStyles.None, CultureInfo.InvariantCulture, out var port)
|| port is < 1 or > 65535)
return $"{field} must be 'host:port' with port 1-65535 (was '{value}')";
return null;
diff --git a/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ServiceCollectionExtensions.cs b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ServiceCollectionExtensions.cs
index 7456f72..25db784 100644
--- a/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ServiceCollectionExtensions.cs
+++ b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ServiceCollectionExtensions.cs
@@ -1,5 +1,6 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.Configuration;
@@ -33,7 +34,7 @@ public static class ServiceCollectionExtensions
ArgumentNullException.ThrowIfNull(configuration);
ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath);
- services.AddSingleton, TValidator>();
+ services.TryAddEnumerable(ServiceDescriptor.Singleton, TValidator>());
return services.AddOptions()
.Bind(configuration.GetSection(sectionPath))
.ValidateOnStart();
diff --git a/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ZB.MOM.WW.Configuration.csproj b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ZB.MOM.WW.Configuration.csproj
index feb7233..88654db 100644
--- a/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ZB.MOM.WW.Configuration.csproj
+++ b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ZB.MOM.WW.Configuration.csproj
@@ -7,7 +7,11 @@
configuration;options;validation;ivalidateoptions;validateonstart;startup;scada;wonderware;zb-mom-wwhttps://gitea.dohertylan.com/dohertj2/zb-mom-ww-configurationhttps://gitea.dohertylan.com/dohertj2/zb-mom-ww-configuration
+ README.md
+
+
+
diff --git a/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/AddValidatedOptionsTests.cs b/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/AddValidatedOptionsTests.cs
index 13d7d18..4329e7f 100644
--- a/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/AddValidatedOptionsTests.cs
+++ b/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/AddValidatedOptionsTests.cs
@@ -44,4 +44,24 @@ public sealed class AddValidatedOptionsTests
Assert.Equal("central", opts.Name);
await host.StopAsync();
}
+
+ [Fact]
+ public void Calling_twice_registers_validator_once()
+ {
+ var config = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary { ["Node:Port"] = "0", ["Node:Name"] = "" })
+ .Build();
+ var services = new ServiceCollection();
+ services.AddValidatedOptions(config, "Node");
+ services.AddValidatedOptions(config, "Node");
+
+ using var provider = services.BuildServiceProvider();
+ var validators = provider.GetServices>().ToArray();
+ Assert.Single(validators);
+
+ // Resolving the options surfaces each accumulated failure exactly once, not doubled.
+ var ex = Assert.Throws(
+ () => provider.GetRequiredService>().Value);
+ Assert.Equal(2, ex.Failures.Count());
+ }
}
diff --git a/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ChecksWordingTests.cs b/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ChecksWordingTests.cs
new file mode 100644
index 0000000..29a794e
--- /dev/null
+++ b/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ChecksWordingTests.cs
@@ -0,0 +1,92 @@
+using Microsoft.Extensions.Configuration;
+using ZB.MOM.WW.Configuration;
+
+namespace ZB.MOM.WW.Configuration.Tests;
+
+///
+/// Pins the exact failure-message wording produced by the shared Checks seam through its
+/// public front-ends ( for raw port values,
+/// for host:port endpoints). Covers Configuration-002 (consistent quoting) and Configuration-003
+/// (strict, culture-invariant port parsing).
+///
+public sealed class ChecksWordingTests
+{
+ private static IConfiguration Config(string key, string? value) =>
+ new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary { [key] = value })
+ .Build();
+
+ private static string PortFailure(string? rawValue)
+ {
+ var pf = ConfigPreflight.For(Config("X:Port", rawValue)).RequirePort("X:Port");
+ return Assert.Single(pf.Failures);
+ }
+
+ // Configuration-002: range failure and parse failure must quote the offending value the same way.
+
+ [Fact]
+ public void PortValue_range_failure_quotes_the_value()
+ {
+ Assert.Equal("X:Port must be between 1 and 65535 (was '0')", PortFailure("0"));
+ }
+
+ [Fact]
+ public void PortValue_high_range_failure_quotes_the_value()
+ {
+ Assert.Equal("X:Port must be between 1 and 65535 (was '70000')", PortFailure("70000"));
+ }
+
+ [Fact]
+ public void PortValue_parse_failure_quotes_the_value()
+ {
+ Assert.Equal("X:Port must be between 1 and 65535 (was 'notaport')", PortFailure("notaport"));
+ }
+
+ [Fact]
+ public void PortValue_null_failure_renders_null()
+ {
+ Assert.Equal("X:Port must be between 1 and 65535 (was 'null')", PortFailure(null));
+ }
+
+ // Configuration-003: strict, culture-invariant parsing rejects sign and surrounding whitespace.
+
+ [Theory]
+ [InlineData("+5000")]
+ [InlineData(" 5000")]
+ [InlineData("5000 ")]
+ [InlineData(" 5000 ")]
+ [InlineData("-1")]
+ public void PortValue_rejects_loose_inputs(string raw)
+ {
+ var pf = ConfigPreflight.For(Config("X:Port", raw)).RequirePort("X:Port");
+ Assert.False(pf.IsValid);
+ Assert.Equal($"X:Port must be between 1 and 65535 (was '{raw}')", Assert.Single(pf.Failures));
+ }
+
+ [Fact]
+ public void PortValue_accepts_plain_in_range_port()
+ {
+ var pf = ConfigPreflight.For(Config("X:Port", "5000")).RequirePort("X:Port");
+ Assert.True(pf.IsValid);
+ }
+
+ [Theory]
+ [InlineData("host:+5000")]
+ [InlineData("host: 5000")]
+ [InlineData("host:5000 ")]
+ public void HostPort_rejects_loose_port_inputs(string value)
+ {
+ var b = new ValidationBuilder();
+ b.HostPort(value, "X:Endpoint");
+ Assert.False(b.IsValid);
+ Assert.Equal($"X:Endpoint must be 'host:port' with port 1-65535 (was '{value}')", Assert.Single(b.Failures));
+ }
+
+ [Fact]
+ public void HostPort_accepts_plain_endpoint()
+ {
+ var b = new ValidationBuilder();
+ b.HostPort("host:5000", "X:Endpoint");
+ Assert.True(b.IsValid);
+ }
+}
diff --git a/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ZB.MOM.WW.Configuration.Tests.csproj b/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ZB.MOM.WW.Configuration.Tests.csproj
index a4e7f02..147ba5d 100644
--- a/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ZB.MOM.WW.Configuration.Tests.csproj
+++ b/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ZB.MOM.WW.Configuration.Tests.csproj
@@ -1,6 +1,8 @@
false
+
+ false
diff --git a/ZB.MOM.WW.Health/Directory.Build.props b/ZB.MOM.WW.Health/Directory.Build.props
index c4755a6..4b5ab79 100644
--- a/ZB.MOM.WW.Health/Directory.Build.props
+++ b/ZB.MOM.WW.Health/Directory.Build.props
@@ -7,6 +7,11 @@
latest0.1.0true
+
+ true
+ $(NoWarn);CS1591
diff --git a/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health.Akka/ActiveNodeHealthCheck.cs b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health.Akka/ActiveNodeHealthCheck.cs
index a9b54d3..df76da4 100644
--- a/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health.Akka/ActiveNodeHealthCheck.cs
+++ b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health.Akka/ActiveNodeHealthCheck.cs
@@ -103,27 +103,41 @@ public sealed class ActiveNodeHealthCheck : IHealthCheck
if (system is null)
return Task.FromResult(HealthCheckResult.Degraded("ActorSystem not yet available."));
- var cluster = Cluster.Get(system);
- var self = cluster.SelfMember;
- var selfUp = self.Status == MemberStatus.Up;
-
+ MemberStatus selfStatus;
+ bool selfUp;
bool hasRole;
bool isLeader;
- if (_role is null)
+ try
{
- hasRole = false;
- var leader = cluster.State.Leader;
- isLeader = leader is not null && leader == self.Address;
+ // Reading cluster membership can throw while the ActorSystem exists but the cluster has
+ // not finished initialising (e.g. Akka.Cluster not yet configured →
+ // ConfigurationException). The spec's startup-safety rule maps this to Degraded rather
+ // than letting the exception escape (which the host would record as Unhealthy).
+ var cluster = Cluster.Get(system);
+ var self = cluster.SelfMember;
+ selfStatus = self.Status;
+ selfUp = selfStatus == MemberStatus.Up;
+
+ if (_role is null)
+ {
+ hasRole = false;
+ var leader = cluster.State.Leader;
+ isLeader = leader is not null && leader == self.Address;
+ }
+ else
+ {
+ hasRole = self.HasRole(_role);
+ var roleLeader = cluster.State.RoleLeader(_role);
+ isLeader = roleLeader is not null && roleLeader == self.Address;
+ }
}
- else
+ catch (Exception ex) when (ex is not OperationCanceledException)
{
- hasRole = self.HasRole(_role);
- var roleLeader = cluster.State.RoleLeader(_role);
- isLeader = roleLeader is not null && roleLeader == self.Address;
+ return Task.FromResult(HealthCheckResult.Degraded("Akka cluster state not yet accessible.", ex));
}
var health = ActiveNodeDecision.Evaluate(selfUp, isLeader, hasRole, _role);
- var description = DescribeResult(health, self.Status, selfUp, isLeader);
+ var description = DescribeResult(health, selfStatus, selfUp, isLeader);
var result = health switch
{
HealthStatus.Healthy => HealthCheckResult.Healthy(description),
diff --git a/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health.Akka/AkkaClusterHealthCheck.cs b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health.Akka/AkkaClusterHealthCheck.cs
index 74e040b..302b2b2 100644
--- a/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health.Akka/AkkaClusterHealthCheck.cs
+++ b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health.Akka/AkkaClusterHealthCheck.cs
@@ -8,7 +8,8 @@ namespace ZB.MOM.WW.Health.Akka;
///
/// Health check that maps the local node's Akka cluster membership status to a
/// through a configurable .
-/// Register to the tag (recommended [ready, active]).
+/// Register to the tag only — cluster membership is a readiness
+/// concern; the tier is reserved for the leader / active-node probe.
///
///
/// The is resolved lazily from the service provider. If it is not yet
@@ -42,7 +43,21 @@ public sealed class AkkaClusterHealthCheck : IHealthCheck
if (system is null)
return Task.FromResult(HealthCheckResult.Degraded("ActorSystem not yet available."));
- var status = Cluster.Get(system).SelfMember.Status;
+ MemberStatus status;
+ try
+ {
+ // Cluster.Get(system).SelfMember can throw while the ActorSystem exists but the cluster
+ // has not finished initialising (e.g. Akka.Cluster not yet configured →
+ // ConfigurationException). The spec's startup-safety rule maps this to Degraded, not an
+ // escaping exception (which the host would record as Unhealthy and pull the node from
+ // rotation).
+ status = Cluster.Get(system).SelfMember.Status;
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ return Task.FromResult(HealthCheckResult.Degraded("Akka cluster state not yet accessible.", ex));
+ }
+
var health = _policy.Evaluate(status);
var description = $"Akka cluster member status: {status}";
var result = health switch
diff --git a/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/GrpcDependencyHealthCheck.cs b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/GrpcDependencyHealthCheck.cs
index e1aa4c2..51bbdf2 100644
--- a/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/GrpcDependencyHealthCheck.cs
+++ b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/GrpcDependencyHealthCheck.cs
@@ -13,14 +13,15 @@ namespace ZB.MOM.WW.Health;
/// The probe is injectable via ; the default drives the
/// channel to a connected state with . The result is
/// when the probe returns true, and
-/// when it returns false, throws an
-/// , or times out / is cancelled within
-/// .
+/// when it returns false, throws any exception
+/// ( or otherwise), or times out within
+/// . External cancellation of the supplied
+/// propagates as an .
///
///
-/// Recommended registration tags: and
-/// — a missing downstream gRPC dependency makes the node both
-/// not-ready and not-able-to-act. The registrant applies the tags.
+/// Recommended registration tag: only — downstream gRPC
+/// reachability is a readiness concern; the tier is reserved for
+/// the leader / active-node probe. The registrant applies the tag.
///
///
public sealed class GrpcDependencyHealthCheck : IHealthCheck
@@ -74,6 +75,15 @@ public sealed class GrpcDependencyHealthCheck : IHealthCheck
{
return HealthCheckResult.Unhealthy($"{name} probe timed out after {_options.Timeout}.", ex);
}
+ catch (Exception ex)
+ {
+ // Catch-all to match the sibling DatabaseHealthCheck: any other probe error
+ // (e.g. InvalidOperationException / HttpRequestException / SocketException from the
+ // transport, or anything a custom probe throws) maps to Unhealthy rather than escaping
+ // the IHealthCheck boundary. The OCE/Rpc external-cancellation handlers above run first,
+ // so caller cancellation still propagates.
+ return HealthCheckResult.Unhealthy($"{name} probe failed: {ex.Message}", ex);
+ }
}
///
diff --git a/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/ZbHealthEndpointExtensions.cs b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/ZbHealthEndpointExtensions.cs
index 780490f..f25dc30 100644
--- a/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/ZbHealthEndpointExtensions.cs
+++ b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/ZbHealthEndpointExtensions.cs
@@ -28,9 +28,9 @@ public static class ZbHealthEndpointExtensions
/// emits a minimal 200 OK body.
///
///
- /// The for the readiness (/health/ready) endpoint.
- /// A single tier is returned (rather than a composite) to keep the API simple; conventions
- /// applied to the result affect only the readiness endpoint.
+ /// A composite that fans every chained convention out to
+ /// all three health endpoints (readiness, active, and liveness). For example,
+ /// endpoints.MapZbHealth().RequireHost("…") gates all three endpoints, as a caller expects.
///
public static IEndpointConventionBuilder MapZbHealth(
this IEndpointRouteBuilder endpoints,
@@ -47,7 +47,7 @@ public static class ZbHealthEndpointExtensions
ResponseWriter = responseWriter,
}).AllowAnonymous();
- endpoints.MapHealthChecks(options.ActivePath, new HealthCheckOptions
+ var active = endpoints.MapHealthChecks(options.ActivePath, new HealthCheckOptions
{
Predicate = static c => c.Tags.Contains(ZbHealthTags.Active),
ResponseWriter = responseWriter,
@@ -56,12 +56,38 @@ public static class ZbHealthEndpointExtensions
// Liveness: run no checks. The endpoint returns 200 as long as the process can respond.
// No JSON writer — the empty report would carry no useful data, so the framework default
// (a minimal plain-text body) is sufficient.
- endpoints.MapHealthChecks(options.LivePath, new HealthCheckOptions
+ var live = endpoints.MapHealthChecks(options.LivePath, new HealthCheckOptions
{
Predicate = static _ => false,
}).AllowAnonymous();
- return ready;
+ return new CompositeEndpointConventionBuilder(ready, active, live);
+ }
+
+ ///
+ /// An that forwards each convention to several
+ /// underlying builders, so conventions chained onto the result of
+ /// apply to all three
+ /// health endpoints rather than just one.
+ ///
+ private sealed class CompositeEndpointConventionBuilder : IEndpointConventionBuilder
+ {
+ private readonly IEndpointConventionBuilder[] _builders;
+
+ public CompositeEndpointConventionBuilder(params IEndpointConventionBuilder[] builders) =>
+ _builders = builders;
+
+ public void Add(Action convention)
+ {
+ foreach (var builder in _builders)
+ builder.Add(convention);
+ }
+
+ public void Finally(Action finalConvention)
+ {
+ foreach (var builder in _builders)
+ builder.Finally(finalConvention);
+ }
}
///
@@ -70,7 +96,10 @@ public static class ZbHealthEndpointExtensions
///
/// The endpoint route builder to map onto.
/// Callback that mutates a fresh .
- /// The for the readiness endpoint.
+ ///
+ /// A composite that fans chained conventions out to all
+ /// three health endpoints.
+ ///
public static IEndpointConventionBuilder MapZbHealth(
this IEndpointRouteBuilder endpoints,
Action configure)
diff --git a/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/ZbHealthWriter.cs b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/ZbHealthWriter.cs
index 401626c..8aa41a1 100644
--- a/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/ZbHealthWriter.cs
+++ b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/ZbHealthWriter.cs
@@ -1,5 +1,4 @@
using System.Text.Json;
-using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Diagnostics.HealthChecks;
@@ -21,15 +20,21 @@ namespace ZB.MOM.WW.Health;
/// }
/// }
///
-/// The HTTP status code is left to the ASP.NET Core health-checks middleware (Healthy/Degraded → 200,
-/// Unhealthy → 503); this writer only renders the body and sets Content-Type: application/json.
+/// The description key is always present; when a check supplies no description it is emitted
+/// as JSON null (not omitted), matching the spec example and the HealthChecks.UI.Client
+/// shape. The HTTP status code is left to the ASP.NET Core health-checks middleware (Healthy/Degraded
+/// → 200, Unhealthy → 503); this writer only renders the body and sets
+/// Content-Type: application/json.
///
public static class ZbHealthWriter
{
+ // Null properties are emitted (not omitted) so a null `description` renders as
+ // "description": null — matching the SPEC §3 example and the HealthChecks.UI.Client shape this
+ // writer mirrors. Consumers can then read entries..description without handling a missing
+ // property. (Do not set DefaultIgnoreCondition = WhenWritingNull here.)
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
- DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
///
diff --git a/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Akka.Tests/ActiveNodeDecisionTests.cs b/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Akka.Tests/ActiveNodeDecisionTests.cs
index 32b6063..2cb5fab 100644
--- a/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Akka.Tests/ActiveNodeDecisionTests.cs
+++ b/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Akka.Tests/ActiveNodeDecisionTests.cs
@@ -86,6 +86,50 @@ public sealed class ActiveNodeDecisionTests
Assert.False(gate.IsActiveNode);
}
+ [Fact]
+ public async Task HealthCheck_RoleLess_ClusterInaccessible_ReturnsDegraded()
+ {
+ // ActorSystem present but Akka.Cluster not configured → Cluster.Get throws. The check must
+ // return Degraded (startup-safety rule), not let the exception escape (→ Unhealthy).
+ using var system = ActorSystem.Create("plain-no-cluster-roleless");
+ try
+ {
+ var provider = new ServiceCollection()
+ .AddSingleton(system)
+ .BuildServiceProvider();
+ var check = new ActiveNodeHealthCheck(provider);
+
+ var result = await check.CheckHealthAsync(NewContext(check));
+
+ Assert.Equal(HealthStatus.Degraded, result.Status);
+ }
+ finally
+ {
+ await system.Terminate();
+ }
+ }
+
+ [Fact]
+ public async Task HealthCheck_RoleFiltered_ClusterInaccessible_ReturnsDegraded()
+ {
+ using var system = ActorSystem.Create("plain-no-cluster-rolefiltered");
+ try
+ {
+ var provider = new ServiceCollection()
+ .AddSingleton(system)
+ .BuildServiceProvider();
+ var check = new ActiveNodeHealthCheck(provider, "admin");
+
+ var result = await check.CheckHealthAsync(NewContext(check));
+
+ Assert.Equal(HealthStatus.Degraded, result.Status);
+ }
+ finally
+ {
+ await system.Terminate();
+ }
+ }
+
private static HealthCheckContext NewContext(IHealthCheck check) => new()
{
Registration = new HealthCheckRegistration("active-node", check, HealthStatus.Unhealthy, tags: null),
diff --git a/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Akka.Tests/AkkaClusterStatusPolicyTests.cs b/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Akka.Tests/AkkaClusterStatusPolicyTests.cs
index 406ac47..0c8bd10 100644
--- a/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Akka.Tests/AkkaClusterStatusPolicyTests.cs
+++ b/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Akka.Tests/AkkaClusterStatusPolicyTests.cs
@@ -1,3 +1,4 @@
+using Akka.Actor;
using Akka.Cluster;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
@@ -70,6 +71,30 @@ public sealed class AkkaClusterStatusPolicyTests
Assert.Equal(HealthStatus.Degraded, result.Status);
}
+ [Fact]
+ public async Task HealthCheck_ActorSystemPresentButClusterInaccessible_ReturnsDegraded()
+ {
+ // A plain (non-clustered) ActorSystem exists in DI, but Akka.Cluster is not configured,
+ // so Cluster.Get(system) throws a ConfigurationException — the startup race the spec calls
+ // out. The check must return Degraded, not let the exception escape (→ Unhealthy via the host).
+ using var system = ActorSystem.Create("plain-no-cluster");
+ try
+ {
+ var provider = new ServiceCollection()
+ .AddSingleton(system)
+ .BuildServiceProvider();
+ var check = new AkkaClusterHealthCheck(provider, AkkaClusterStatusPolicy.Default);
+
+ var result = await check.CheckHealthAsync(NewContext(check));
+
+ Assert.Equal(HealthStatus.Degraded, result.Status);
+ }
+ finally
+ {
+ await system.Terminate();
+ }
+ }
+
private static HealthCheckContext NewContext(IHealthCheck check) => new()
{
Registration = new HealthCheckRegistration("akka-cluster", check, HealthStatus.Unhealthy, tags: null),
diff --git a/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Tests/GrpcDependencyHealthCheckTests.cs b/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Tests/GrpcDependencyHealthCheckTests.cs
index 33769e4..df37e4c 100644
--- a/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Tests/GrpcDependencyHealthCheckTests.cs
+++ b/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Tests/GrpcDependencyHealthCheckTests.cs
@@ -71,6 +71,21 @@ public sealed class GrpcDependencyHealthCheckTests
Assert.Contains("mxaccessgw worker", result.Description);
}
+ [Fact]
+ public async Task ProbeThrowsArbitraryException_Unhealthy()
+ {
+ // A non-RpcException / non-OperationCanceledException (e.g. the transport surfacing an
+ // InvalidOperationException) must be caught and mapped to Unhealthy, not allowed to escape.
+ var result = await RunAsync(new GrpcDependencyOptions
+ {
+ DependencyName = "mxaccessgw worker",
+ Probe = static (_, _) => throw new InvalidOperationException("channel disposed"),
+ });
+
+ Assert.Equal(HealthStatus.Unhealthy, result.Status);
+ Assert.Contains("mxaccessgw worker", result.Description);
+ }
+
[Fact]
public async Task ProbeExceedsTimeout_Unhealthy()
{
diff --git a/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Tests/ResponseWriterTests.cs b/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Tests/ResponseWriterTests.cs
index dd2716f..0e9bd6b 100644
--- a/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Tests/ResponseWriterTests.cs
+++ b/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Tests/ResponseWriterTests.cs
@@ -31,7 +31,7 @@ public sealed class ResponseWriterTests
}
private static async Task GetReadyAsync(
- HealthStatus status, string description = "db reachable")
+ HealthStatus status, string? description = "db reachable")
{
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
@@ -66,6 +66,24 @@ public sealed class ResponseWriterTests
Assert.Equal("db reachable", db.GetProperty("description").GetString());
}
+ [Fact]
+ public async Task ReadyEndpoint_NullDescription_EmitsDescriptionKeyAsNull()
+ {
+ // A check that produces no description must still emit the "description" key with a JSON null
+ // value (matching the spec §3 example and the HealthChecks.UI.Client shape) rather than
+ // dropping the key — so consumers can read entries..description without handling a
+ // missing property.
+ var response = await GetReadyAsync(HealthStatus.Healthy, description: null);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
+ var db = doc.RootElement.GetProperty("entries").GetProperty("db");
+
+ Assert.True(db.TryGetProperty("description", out var description), "description key must be present");
+ Assert.Equal(JsonValueKind.Null, description.ValueKind);
+ }
+
[Fact]
public async Task ReadyEndpoint_Degraded_Returns200_WithDegradedStatus()
{
diff --git a/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Tests/TierMappingTests.cs b/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Tests/TierMappingTests.cs
index 2cc8fbd..29b4a92 100644
--- a/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Tests/TierMappingTests.cs
+++ b/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Tests/TierMappingTests.cs
@@ -127,6 +127,31 @@ public sealed class TierMappingTests
Assert.Equal(0, active.Invocations);
}
+ [Fact]
+ public async Task ChainedConvention_AppliesToAllThreeEndpoints()
+ {
+ // MapZbHealth returns a composite builder, so a convention chained onto its result
+ // (.RequireHost) must gate all three endpoints — not just readiness. With a host filter
+ // that does not match the default test-client host, every tier returns 404.
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ builder.Services.AddHealthChecks()
+ .AddCheck("ready-check", new RecordingHealthCheck(HealthStatus.Healthy), tags: new[] { ZbHealthTags.Ready })
+ .AddCheck("active-check", new RecordingHealthCheck(HealthStatus.Healthy), tags: new[] { ZbHealthTags.Active });
+
+ await using var app = builder.Build();
+ app.MapZbHealth().RequireHost("health.internal");
+ await app.StartAsync();
+
+ var client = app.GetTestClient();
+
+ // The default test host does not match "health.internal", so the convention removed every
+ // endpoint from this host — confirming it fanned out to all three, not just readiness.
+ Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/health/ready")).StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/health/active")).StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/healthz")).StatusCode);
+ }
+
[Fact]
public async Task Options_OverrideRoutePaths()
{
diff --git a/ZB.MOM.WW.Telemetry/README.md b/ZB.MOM.WW.Telemetry/README.md
index 5380dbd..1b17d61 100644
--- a/ZB.MOM.WW.Telemetry/README.md
+++ b/ZB.MOM.WW.Telemetry/README.md
@@ -55,6 +55,14 @@ Trace↔log correlation is automatic: `TraceContextEnricher` reads `Activity.Cur
log event and attaches `trace_id` and `span_id`, so log events produced inside a traced request
carry the same span identity as the trace backend.
+**Redaction reach.** A registered `ILogRedactor` may **remove** or **replace** any top-level
+property, and `RedactionEnricher` honours both (a removed key is dropped from the event). The seam
+sees the unwrapped value of scalar properties only — a destructured `{@Object}` property is exposed
+as its raw Serilog `StructureValue` wrapper, so a redactor can replace/remove the whole structured
+property but **cannot** mask a field nested inside it. To protect a sensitive field of a logged
+object, log it as its own scalar property (do not destructure it) or remove the whole property by
+key. See the `ILogRedactor` XML doc for the full contract.
+
---
## Exporter options
@@ -113,9 +121,9 @@ backend):
| Assembly | Tests |
|---|---|
-| `ZB.MOM.WW.Telemetry.Tests` | 7 |
-| `ZB.MOM.WW.Telemetry.Serilog.Tests` | 12 |
-| **Total** | **19** |
+| `ZB.MOM.WW.Telemetry.Tests` | 12 |
+| `ZB.MOM.WW.Telemetry.Serilog.Tests` | 17 |
+| **Total** | **29** |
---
diff --git a/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry.Serilog/ILogRedactor.cs b/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry.Serilog/ILogRedactor.cs
index 0df8683..049adf0 100644
--- a/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry.Serilog/ILogRedactor.cs
+++ b/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry.Serilog/ILogRedactor.cs
@@ -11,6 +11,20 @@ public interface ILogRedactor
///
/// Inspects and mutates the supplied log-event in place — remove
/// or replace any sensitive values. Called on every log event before it reaches any sink.
+ /// Both removing a key (the property is dropped from the event) and replacing its value are
+ /// honoured by .
+ ///
+ /// Reach — scalar top-level properties only. Each entry's value is the unwrapped scalar
+ /// of a Serilog ScalarValue property (so simple string/number/etc. properties such as
+ /// {apiKey} can be read and masked directly). Destructured / structured properties are
+ /// not unwrapped: a {@Object} property arrives as the raw Serilog
+ /// StructureValue wrapper (and a sequence/dictionary as SequenceValue/
+ /// DictionaryValue). A redactor can therefore replace or remove the whole
+ /// top-level property, but it cannot reach a field nested inside a destructured object to
+ /// mask it selectively. To protect a sensitive field of a logged object, do not destructure it
+ /// (log the field as its own scalar property), or remove/replace the entire structured property
+ /// by key.
+ ///
///
/// The mutable property dictionary for the current log event.
void Redact(IDictionary properties);
diff --git a/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry.Serilog/RedactionEnricher.cs b/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry.Serilog/RedactionEnricher.cs
index 79b873d..1251bf9 100644
--- a/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry.Serilog/RedactionEnricher.cs
+++ b/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry.Serilog/RedactionEnricher.cs
@@ -30,8 +30,18 @@ public sealed class RedactionEnricher : ILogEventEnricher
}
///
- /// Hands the log event's scalar properties to the registered and
- /// writes back any values the redactor changed. No-op when no redactor is registered.
+ /// Hands the log event's properties to the registered and reconciles
+ /// the result back onto the event: values the redactor changed are rewritten via
+ /// AddOrUpdateProperty, and keys the redactor removed are deleted via
+ /// RemovePropertyIfPresent. No-op when no redactor is registered or the event carries no
+ /// properties.
+ ///
+ /// The redactor sees the unwrapped value of each property; structured
+ /// values ( from {@Object}, ,
+ /// ) are passed through as their raw Serilog wrapper. A redactor can
+ /// therefore replace or remove a whole structured top-level property, but cannot reach a field
+ /// nested inside one — see for the seam's documented reach.
+ ///
///
/// The log event to redact.
/// Factory used to materialize replacement properties.
@@ -46,6 +56,12 @@ public sealed class RedactionEnricher : ILogEventEnricher
return;
}
+ // Hot path: an event with no properties has nothing to redact — skip the snapshot copy.
+ if (logEvent.Properties.Count == 0)
+ {
+ return;
+ }
+
var snapshot = new Dictionary(logEvent.Properties.Count);
foreach (var property in logEvent.Properties)
{
@@ -54,6 +70,10 @@ public sealed class RedactionEnricher : ILogEventEnricher
: property.Value;
}
+ // Capture the original key set so we can honour deletions: any key the redactor drops from
+ // the snapshot must be removed from the event (not silently retained).
+ var originalKeys = new HashSet(snapshot.Keys, StringComparer.Ordinal);
+
redactor.Redact(snapshot);
foreach (var entry in snapshot)
@@ -64,6 +84,16 @@ public sealed class RedactionEnricher : ILogEventEnricher
propertyFactory.CreateProperty(entry.Key, entry.Value));
}
}
+
+ // Reconcile removals: a redactor that deleted a key from the snapshot (e.g.
+ // properties.Remove("apiKey")) means that property must not reach any sink.
+ foreach (var key in originalKeys)
+ {
+ if (!snapshot.ContainsKey(key))
+ {
+ logEvent.RemovePropertyIfPresent(key);
+ }
+ }
}
private ILogRedactor? ResolveRedactor() => _redactor.Value;
diff --git a/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry.Serilog/ZbSerilogConfig.cs b/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry.Serilog/ZbSerilogConfig.cs
index f54d49b..0053a5d 100644
--- a/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry.Serilog/ZbSerilogConfig.cs
+++ b/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry.Serilog/ZbSerilogConfig.cs
@@ -115,38 +115,13 @@ internal static class ZbSerilogConfig
}
///
- /// Builds the OTLP Resource-attribute map mirroring ZbResource. Null/empty optional
- /// attributes are omitted, matching the shared Resource's omission rules. The
- /// service.instance.id is sourced from — the
- /// same deterministic MachineName:ProcessId value used by the OTel SDK path — so
- /// all three signals carry an identical instance identifier. Internal so it can be asserted
- /// by the test assembly without being part of the public NuGet API.
+ /// Builds the OTLP log-sink Resource-attribute map. This is not a parallel
+ /// implementation: it is derived directly from — the
+ /// single source of truth shared with the OTel SDK metrics/traces pipeline — so the log sink can
+ /// never drift from metrics and traces. Returned as a fresh mutable copy because the
+ /// Serilog OpenTelemetry sink takes ownership of the dictionary it is handed. Internal so it can
+ /// be asserted by the test assembly without being part of the public NuGet API.
///
- internal static IDictionary BuildResourceAttributes(ZbTelemetryOptions options)
- {
- var attributes = new Dictionary
- {
- ["service.name"] = options.ServiceName,
- ["service.namespace"] = options.ServiceNamespace,
- ["service.instance.id"] = ZbResource.InstanceId,
- ["host.name"] = Environment.MachineName,
- };
-
- if (!string.IsNullOrEmpty(options.ServiceVersion))
- {
- attributes["service.version"] = options.ServiceVersion;
- }
-
- if (!string.IsNullOrEmpty(options.SiteId))
- {
- attributes["site.id"] = options.SiteId;
- }
-
- if (!string.IsNullOrEmpty(options.NodeRole))
- {
- attributes["node.role"] = options.NodeRole;
- }
-
- return attributes;
- }
+ internal static IDictionary BuildResourceAttributes(ZbTelemetryOptions options) =>
+ new Dictionary(ZbResource.BuildAttributes(options), StringComparer.Ordinal);
}
diff --git a/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry.Serilog/ZbSerilogExtensions.cs b/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry.Serilog/ZbSerilogExtensions.cs
index 8150434..b259b87 100644
--- a/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry.Serilog/ZbSerilogExtensions.cs
+++ b/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry.Serilog/ZbSerilogExtensions.cs
@@ -65,6 +65,10 @@ public static class ZbSerilogExtensions
var options = new ZbTelemetryOptions();
configure(options);
+ // Fail fast on a malformed OTLP endpoint with a clear, named message — same validation the
+ // core AddZbTelemetry path uses — instead of a late error when the OTel log sink builds.
+ ZbTelemetryOptionsValidator.Validate(options, nameof(configure));
+
// Register the application logger in DI only. preserveStaticLogger: true ensures
// AddSerilog does NOT freeze or replace Log.Logger — critical for multi-host
// processes (integration tests etc.) where AddZbSerilog may be called more than once.
diff --git a/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry/ZB.MOM.WW.Telemetry.csproj b/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry/ZB.MOM.WW.Telemetry.csproj
index 77a1b66..13fee8c 100644
--- a/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry/ZB.MOM.WW.Telemetry.csproj
+++ b/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry/ZB.MOM.WW.Telemetry.csproj
@@ -20,6 +20,17 @@
+
+
+
+ <_Parameter1>ZB.MOM.WW.Telemetry.Serilog
+
+
+ <_Parameter1>ZB.MOM.WW.Telemetry.Serilog.Tests
+
+
+
diff --git a/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry/ZbMetricsEndpointExtensions.cs b/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry/ZbMetricsEndpointExtensions.cs
index ee1f88b..96b4bb7 100644
--- a/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry/ZbMetricsEndpointExtensions.cs
+++ b/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry/ZbMetricsEndpointExtensions.cs
@@ -9,8 +9,10 @@ namespace ZB.MOM.WW.Telemetry;
public static class ZbMetricsEndpointExtensions
{
///
- /// Mounts the Prometheus /metrics endpoint. Only valid when
- /// = .
+ /// Mounts the Prometheus /metrics endpoint. Valid under any
+ /// value: the Prometheus exporter is always wired by
+ /// AddZbTelemetry, and OTLP () is only an additive overlay —
+ /// so /metrics serves scrape data even when Exporter = ZbExporter.Otlp.
/// Call after app.UseRouting().
///
/// The endpoint route builder.
diff --git a/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry/ZbResource.cs b/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry/ZbResource.cs
index ccda9c7..3c8b962 100644
--- a/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry/ZbResource.cs
+++ b/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry/ZbResource.cs
@@ -31,34 +31,55 @@ public static class ZbResource
Configure(ResourceBuilder.CreateDefault(), options);
///
- /// Applies the shared ZB.MOM.WW Resource attributes to an existing .
- /// Internal seam so the AddZbTelemetry pipeline produces a Resource identical to
- /// .
+ /// The single source of truth for the shared ZB.MOM.WW Resource attribute set. Every consumer
+ /// of the Resource — the OTel SDK metrics/traces pipeline () and the
+ /// Serilog OTLP log sink — derives its attributes from this one map, so logs can never drift
+ /// from metrics/traces. Required attributes (service.name, service.namespace,
+ /// service.instance.id, host.name) are always present; optional ones
+ /// (service.version, site.id, node.role) are included only when the
+ /// corresponding option is non-null/non-empty, matching the Resource's omission rules.
///
- internal static ResourceBuilder Configure(ResourceBuilder builder, ZbTelemetryOptions options)
+ /// The telemetry options describing the service identity.
+ /// The canonical attribute map carried by all three signals.
+ public static IReadOnlyDictionary BuildAttributes(ZbTelemetryOptions options)
{
- builder.AddService(
- serviceName: options.ServiceName,
- serviceNamespace: options.ServiceNamespace,
- serviceVersion: options.ServiceVersion,
- autoGenerateServiceInstanceId: false,
- serviceInstanceId: InstanceId);
+ ArgumentNullException.ThrowIfNull(options);
- var attributes = new List>
+ var attributes = new Dictionary(StringComparer.Ordinal)
{
- new("host.name", System.Environment.MachineName),
+ ["service.name"] = options.ServiceName,
+ ["service.namespace"] = options.ServiceNamespace,
+ ["service.instance.id"] = InstanceId,
+ ["host.name"] = System.Environment.MachineName,
};
+ if (!string.IsNullOrEmpty(options.ServiceVersion))
+ {
+ attributes["service.version"] = options.ServiceVersion;
+ }
+
if (!string.IsNullOrEmpty(options.SiteId))
{
- attributes.Add(new("site.id", options.SiteId));
+ attributes["site.id"] = options.SiteId;
}
if (!string.IsNullOrEmpty(options.NodeRole))
{
- attributes.Add(new("node.role", options.NodeRole));
+ attributes["node.role"] = options.NodeRole;
}
+ return attributes;
+ }
+
+ ///
+ /// Applies the shared ZB.MOM.WW Resource attributes to an existing .
+ /// Internal seam so the AddZbTelemetry pipeline produces a Resource identical to
+ /// . Derives every attribute from — the same
+ /// canonical map the Serilog OTLP log sink uses — so all three signals agree.
+ ///
+ internal static ResourceBuilder Configure(ResourceBuilder builder, ZbTelemetryOptions options)
+ {
+ var attributes = BuildAttributes(options);
builder.AddAttributes(attributes);
return builder;
}
diff --git a/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry/ZbTelemetryExtensions.cs b/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry/ZbTelemetryExtensions.cs
index b44d5b5..34f32c7 100644
--- a/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry/ZbTelemetryExtensions.cs
+++ b/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry/ZbTelemetryExtensions.cs
@@ -101,6 +101,7 @@ public static class ZbTelemetryExtensions
"ZbTelemetryOptions.ServiceName is required (e.g. \"otopcua\").",
nameof(configure));
}
+ ZbTelemetryOptionsValidator.Validate(options, nameof(configure));
return options;
}
diff --git a/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry/ZbTelemetryOptionsValidator.cs b/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry/ZbTelemetryOptionsValidator.cs
new file mode 100644
index 0000000..b0ac495
--- /dev/null
+++ b/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry/ZbTelemetryOptionsValidator.cs
@@ -0,0 +1,44 @@
+namespace ZB.MOM.WW.Telemetry;
+
+///
+/// Eager, fail-fast validation of shared by the core
+/// AddZbTelemetry path and the Serilog AddZbSerilog path, so a malformed value is
+/// reported once, clearly, and with the offending option named — rather than surfacing late as a
+/// bare deep inside exporter construction at host-build time.
+///
+internal static class ZbTelemetryOptionsValidator
+{
+ ///
+ /// Validates the OTLP configuration. When is
+ /// , must be a
+ /// non-empty, well-formed absolute URI. Throws an (naming the
+ /// option) otherwise. No-op for the Prometheus exporter — a stray endpoint is ignored there.
+ ///
+ /// The populated telemetry options to validate.
+ /// The originating parameter name for the thrown exception.
+ public static void Validate(ZbTelemetryOptions options, string paramName)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ if (options.Exporter != ZbExporter.Otlp)
+ {
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(options.OtlpEndpoint))
+ {
+ throw new ArgumentException(
+ "ZbTelemetryOptions.OtlpEndpoint is required when Exporter = ZbExporter.Otlp " +
+ "(e.g. \"http://collector:4317\").",
+ paramName);
+ }
+
+ if (!Uri.TryCreate(options.OtlpEndpoint, UriKind.Absolute, out _))
+ {
+ throw new ArgumentException(
+ $"ZbTelemetryOptions.OtlpEndpoint is not a well-formed absolute URI: " +
+ $"'{options.OtlpEndpoint}' (expected e.g. \"http://collector:4317\").",
+ paramName);
+ }
+ }
+}
diff --git a/ZB.MOM.WW.Telemetry/tests/ZB.MOM.WW.Telemetry.Serilog.Tests/RedactionTests.cs b/ZB.MOM.WW.Telemetry/tests/ZB.MOM.WW.Telemetry.Serilog.Tests/RedactionTests.cs
index bbe2b18..03870de 100644
--- a/ZB.MOM.WW.Telemetry/tests/ZB.MOM.WW.Telemetry.Serilog.Tests/RedactionTests.cs
+++ b/ZB.MOM.WW.Telemetry/tests/ZB.MOM.WW.Telemetry.Serilog.Tests/RedactionTests.cs
@@ -25,6 +25,30 @@ public sealed class RedactionTests
}
}
+ private sealed class RemovingRedactor : ILogRedactor
+ {
+ private readonly string _key;
+
+ public RemovingRedactor(string key) => _key = key;
+
+ public void Redact(IDictionary properties) => properties.Remove(_key);
+ }
+
+ private sealed class StructuredFieldRedactor : ILogRedactor
+ {
+ // Attempts to mask a nested field of a destructured ({@Object}) property by mutating the
+ // value the seam exposes. Documents that the seam reaches scalar top-level properties only.
+ public void Redact(IDictionary properties)
+ {
+ if (properties.TryGetValue("command", out var value) && value is StructureValue)
+ {
+ // The seam exposes the raw StructureValue wrapper, not a mutable dictionary of the
+ // object's fields, so a project redactor cannot reach inside to mask a nested field.
+ properties["command"] = Masked;
+ }
+ }
+ }
+
private static string? ScalarOrNull(LogEvent logEvent, string propertyName) =>
logEvent.Properties.TryGetValue(propertyName, out var value) && value is ScalarValue scalar
? scalar.Value?.ToString()
@@ -68,6 +92,68 @@ public sealed class RedactionTests
Assert.Equal("mxgw_secret", ScalarOrNull(logEvent, "apiKey"));
}
+ ///
+ /// Telemetry-001: a redactor that REMOVES a key (the most natural way to implement "must not
+ /// leave the process") must result in the property being absent from the emitted event, not
+ /// silently retained.
+ ///
+ [Fact]
+ public void Removing_redactor_scrubs_the_property_from_the_event()
+ {
+ var serviceProvider = new ServiceCollection()
+ .AddSingleton(new RemovingRedactor("apiKey"))
+ .BuildServiceProvider();
+
+ var sink = new InMemorySink();
+ var options = new ZbTelemetryOptions { ServiceName = "mxgateway" };
+
+ var loggerConfig = new LoggerConfiguration();
+ ZbSerilogConfig.Apply(loggerConfig, options, serviceProvider);
+ using Logger logger = loggerConfig.WriteTo.Sink(sink).CreateLogger();
+
+ logger.Information("authenticating {apiKey} for {user}", "mxgw_secret", "alice");
+
+ var logEvent = Assert.Single(sink.LogEvents);
+ Assert.False(
+ logEvent.Properties.ContainsKey("apiKey"),
+ "apiKey must be removed from the event when the redactor removes the key");
+ // A non-sensitive property the redactor left alone must survive.
+ Assert.Equal("alice", ScalarOrNull(logEvent, "user"));
+ }
+
+ ///
+ /// Telemetry-002/003: the redaction seam reaches scalar top-level properties only. A
+ /// destructured ({@Object}) property is exposed to the redactor as the raw Serilog
+ /// wrapper, so a project redactor cannot mask a field nested
+ /// inside the object — it can only replace/remove the whole top-level property. This test
+ /// pins that documented limitation (see ILogRedactor XML doc and the shared contract).
+ ///
+ [Fact]
+ public void Redactor_cannot_reach_a_field_inside_a_destructured_object()
+ {
+ var serviceProvider = new ServiceCollection()
+ .AddSingleton(new StructuredFieldRedactor())
+ .BuildServiceProvider();
+
+ var sink = new InMemorySink();
+ var options = new ZbTelemetryOptions { ServiceName = "mxgateway" };
+
+ var loggerConfig = new LoggerConfiguration();
+ ZbSerilogConfig.Apply(loggerConfig, options, serviceProvider);
+ using Logger logger = loggerConfig.WriteTo.Sink(sink).CreateLogger();
+
+ var command = new { Name = "Write", ApiKey = "mxgw_secret" };
+ logger.Information("dispatching {@command}", command);
+
+ var logEvent = Assert.Single(sink.LogEvents);
+ Assert.True(logEvent.Properties.TryGetValue("command", out var value));
+
+ // The property was destructured into a StructureValue and exposed to the redactor as that
+ // wrapper. The redactor recognized it and replaced the whole top-level property with the
+ // mask — confirming the seam can only act at top-level granularity for structured values.
+ Assert.Equal(Masked, (value as ScalarValue)?.Value?.ToString());
+ }
+
[Fact]
public void AddZbSerilog_with_otlp_options_builds_without_error()
{
@@ -122,6 +208,32 @@ public sealed class RedactionTests
Assert.Equal("central", attributes["node.role"]);
}
+ ///
+ /// Telemetry-005: the Serilog OTLP log-sink attribute map and the OTel SDK metrics/traces
+ /// attribute map must be key-for-key and value-for-value identical, because both now derive from
+ /// the single source of truth. This pins that they
+ /// cannot silently drift apart.
+ ///
+ [Fact]
+ public void Serilog_and_OTel_resource_attribute_sets_are_identical()
+ {
+ var options = new ZbTelemetryOptions
+ {
+ ServiceName = "mxgateway",
+ ServiceNamespace = "ZB.MOM.WW",
+ ServiceVersion = "9.9.9",
+ SiteId = "site-z",
+ NodeRole = "hub",
+ };
+
+ var serilogAttributes = ZbSerilogConfig.BuildResourceAttributes(options);
+ var canonical = ZbResource.BuildAttributes(options);
+
+ Assert.Equal(
+ canonical.OrderBy(kvp => kvp.Key, StringComparer.Ordinal),
+ serilogAttributes.OrderBy(kvp => kvp.Key, StringComparer.Ordinal));
+ }
+
[Fact]
public void BuildResourceAttributes_omits_optional_keys_when_not_set()
{
diff --git a/ZB.MOM.WW.Telemetry/tests/ZB.MOM.WW.Telemetry.Tests/AddZbTelemetryTests.cs b/ZB.MOM.WW.Telemetry/tests/ZB.MOM.WW.Telemetry.Tests/AddZbTelemetryTests.cs
index 024859f..427d29d 100644
--- a/ZB.MOM.WW.Telemetry/tests/ZB.MOM.WW.Telemetry.Tests/AddZbTelemetryTests.cs
+++ b/ZB.MOM.WW.Telemetry/tests/ZB.MOM.WW.Telemetry.Tests/AddZbTelemetryTests.cs
@@ -37,6 +37,55 @@ public sealed class AddZbTelemetryTests
Assert.Equal("configure", ex.ParamName);
}
+ // Telemetry-006: malformed/missing OtlpEndpoint must fail fast with a clear, named error
+ // instead of a late UriFormatException deep inside exporter construction.
+
+ [Fact]
+ public void AddZbTelemetry_Throws_WhenOtlpExporterHasMalformedEndpoint()
+ {
+ var builder = WebApplication.CreateBuilder();
+ var ex = Assert.Throws(() =>
+ builder.AddZbTelemetry(o =>
+ {
+ o.ServiceName = "telemetry-test";
+ o.Exporter = ZbExporter.Otlp;
+ o.OtlpEndpoint = "not a uri"; // missing scheme — not an absolute URI
+ }));
+ Assert.Equal("configure", ex.ParamName);
+ Assert.Contains("OtlpEndpoint", ex.Message);
+ }
+
+ [Fact]
+ public void AddZbTelemetry_Throws_WhenOtlpExporterHasNoEndpoint()
+ {
+ var builder = WebApplication.CreateBuilder();
+ var ex = Assert.Throws(() =>
+ builder.AddZbTelemetry(o =>
+ {
+ o.ServiceName = "telemetry-test";
+ o.Exporter = ZbExporter.Otlp;
+ // OtlpEndpoint left null
+ }));
+ Assert.Equal("configure", ex.ParamName);
+ Assert.Contains("OtlpEndpoint", ex.Message);
+ }
+
+ [Fact]
+ public void AddZbTelemetry_DoesNotValidateEndpoint_WhenExporterIsPrometheus()
+ {
+ // A stray (even malformed) endpoint is harmless under the Prometheus exporter and must not
+ // be validated — it is ignored.
+ var builder = WebApplication.CreateBuilder();
+ var ex = Record.Exception(() =>
+ builder.AddZbTelemetry(o =>
+ {
+ o.ServiceName = "telemetry-test";
+ o.Exporter = ZbExporter.Prometheus;
+ o.OtlpEndpoint = "not a uri";
+ }));
+ Assert.Null(ex);
+ }
+
// Fix #1: Prometheus coexists with OTLP — /metrics must still serve under Otlp exporter
[Fact]
@@ -48,8 +97,10 @@ public sealed class AddZbTelemetryTests
{
o.ServiceName = "telemetry-test";
o.Exporter = ZbExporter.Otlp;
- // OtlpEndpoint intentionally left null — exporter will be registered but won't
- // connect anywhere; we are only verifying Prometheus remains present.
+ // A well-formed endpoint is required under the Otlp exporter (Telemetry-006); the
+ // exporter is registered but won't connect anywhere in the test. We are only verifying
+ // Prometheus remains present.
+ o.OtlpEndpoint = "http://localhost:4317";
o.Meters = ["Test.OtlpCoexist.Meter"];
});
diff --git a/ZB.MOM.WW.Theme/README.md b/ZB.MOM.WW.Theme/README.md
index da25a9f..fcb2040 100644
--- a/ZB.MOM.WW.Theme/README.md
+++ b/ZB.MOM.WW.Theme/README.md
@@ -7,6 +7,11 @@ woff2 fonts, and side-rail layout CSS — all served from `_content/ZB.MOM.WW.Th
plus a set of Blazor SSR components that carry no inline colours and reuse the token
classes. Bootstrap 5 is **not** vendored; each app keeps its own Bootstrap link.
+The kit ships **no JavaScript**: every interactive affordance — the narrow-viewport
+side-rail hamburger and the collapsible nav sections — is a native CSS-only
+``/`` disclosure, so the chrome works unchanged in static Blazor SSR
+with only Bootstrap **CSS** present (no Bootstrap collapse JS bundle required).
+
## Adopt
1. Reference the NuGet package in your app; keep your own Bootstrap 5 `` in `App.razor`.
@@ -83,11 +88,16 @@ Served at `_content/ZB.MOM.WW.Theme/…` by the ASP.NET static-web-asset pipelin
`theme.css` declares `@font-face` with `url('../fonts/…')` — correct relative path from
`css/` to `fonts/`. (OtOpcUa's original `url('fonts/…')` was a latent 404; the kit fixes it.)
+All colours are CSS custom-property tokens declared once in `theme.css`'s `:root` block —
+no hardcoded hex appears outside it. Derived border/hover/wash shades (e.g. `--ok-border`,
+`--warn-ink`, `--info-bg`, `--hover-bg`, `--active-bg`, `--zebra-bg`) are named tokens too,
+so a token-block edit re-themes the whole kit.
+
## Build
```bash
# from ZB.MOM.WW.Theme/
dotnet build -c Release # TreatWarningsAsErrors — expect 0 warnings
-dotnet test # 32 bUnit tests
+dotnet test # 38 bUnit tests
./build/pack.sh # → ./artifacts/ZB.MOM.WW.Theme.0.1.0.nupkg
```
diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/ButtonVariant.cs b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/ButtonVariant.cs
index 37d0146..b2d654d 100644
--- a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/ButtonVariant.cs
+++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/ButtonVariant.cs
@@ -1,3 +1,19 @@
namespace ZB.MOM.WW.Theme;
-public enum ButtonVariant { Primary, Secondary, Danger, Ghost }
+///
+/// Visual variant for TechButton, mapped to a Bootstrap .btn-* class.
+///
+public enum ButtonVariant
+{
+ /// Primary action — emits btn-primary (filled accent button).
+ Primary,
+
+ /// Secondary action — emits btn-outline-secondary (outlined button).
+ Secondary,
+
+ /// Destructive action — emits btn-danger (filled red button).
+ Danger,
+
+ /// Low-emphasis action — emits btn-link (text-only button).
+ Ghost,
+}
diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/BrandBar.razor b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/BrandBar.razor
index d1420d9..f462fb1 100644
--- a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/BrandBar.razor
+++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/BrandBar.razor
@@ -7,6 +7,11 @@
@code {
+ /// Product name displayed next to the brand glyph. Required.
[Parameter, EditorRequired] public string Product { get; set; } = string.Empty;
+
+ ///
+ /// Optional custom logo. When provided it replaces the default brand glyph (▐).
+ ///
[Parameter] public RenderFragment? Logo { get; set; }
}
diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/LoginCard.razor b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/LoginCard.razor
index 0f6fb3e..d15fc15 100644
--- a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/LoginCard.razor
+++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/LoginCard.razor
@@ -36,7 +36,12 @@
@code {
+ /// Product name shown in the card heading. Required.
[Parameter, EditorRequired] public string Product { get; set; } = string.Empty;
+
+ ///
+ /// Form action URL the sign-in POST targets. Defaults to /auth/login.
+ ///
[Parameter] public string Action { get; set; } = "/auth/login";
///
@@ -47,6 +52,9 @@
///
[Parameter] public string? ReturnUrl { get; set; }
+ ///
+ /// Optional error message shown as a notice above the submit button (e.g. a failed login).
+ ///
[Parameter] public string? Error { get; set; }
///
diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/NavRailItem.razor b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/NavRailItem.razor
index dd5d982..d1e464d 100644
--- a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/NavRailItem.razor
+++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/NavRailItem.razor
@@ -6,8 +6,21 @@
@code {
+ /// Link target (the href of the underlying NavLink). Required.
[Parameter, EditorRequired] public string Href { get; set; } = string.Empty;
+
+ /// Visible label text for the rail link. Required.
[Parameter, EditorRequired] public string Text { get; set; } = string.Empty;
+
+ ///
+ /// Optional leading icon, rendered in a .rail-ico span before the label.
+ ///
[Parameter] public RenderFragment? Icon { get; set; }
+
+ ///
+ /// Active-class matching behaviour passed to the underlying NavLink.
+ /// Defaults to ; use
+ /// for exact matches such as the home/overview link.
+ ///
[Parameter] public NavLinkMatch Match { get; set; } = NavLinkMatch.Prefix;
}
diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/NavRailSection.razor b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/NavRailSection.razor
index b620734..50268c0 100644
--- a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/NavRailSection.razor
+++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/NavRailSection.razor
@@ -2,12 +2,24 @@
Apps that want cookie-persisted expand state keep their own interactive NavSection. *@
@namespace ZB.MOM.WW.Theme
- @Title
+ @Title
@ChildContent
@code {
+ ///
+ /// Eyebrow label shown in the section's <summary> header. Required.
+ ///
[Parameter, EditorRequired] public string Title { get; set; } = string.Empty;
+
+ ///
+ /// Initial open state of the collapsible section. Defaults to true (expanded).
+ /// Backed by the native <details open> attribute — no JavaScript required.
+ ///
[Parameter] public bool Expanded { get; set; } = true;
+
+ ///
+ /// Section content — typically children.
+ ///
[Parameter] public RenderFragment? ChildContent { get; set; }
}
diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/StatusPill.razor b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/StatusPill.razor
index 99de38a..aaaaf53 100644
--- a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/StatusPill.razor
+++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/StatusPill.razor
@@ -3,7 +3,13 @@
@ChildContent
@code {
+ ///
+ /// Status the chip represents. Mapped to a token-based .chip-* class
+ /// (e.g. → chip-ok). Required.
+ ///
[Parameter, EditorRequired] public StatusState State { get; set; }
+
+ /// Chip label content (e.g. the status text).
[Parameter] public RenderFragment? ChildContent { get; set; }
private string ChipClass => State switch
diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/TechButton.razor b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/TechButton.razor
index 871db30..1cba5f6 100644
--- a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/TechButton.razor
+++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/TechButton.razor
@@ -6,10 +6,28 @@
@code {
+ ///
+ /// Visual variant, mapped to a Bootstrap .btn-* class.
+ /// Defaults to .
+ ///
[Parameter] public ButtonVariant Variant { get; set; } = ButtonVariant.Primary;
+
+ /// HTML type attribute (button, submit, reset). Defaults to button.
[Parameter] public string Type { get; set; } = "button";
+
+ ///
+ /// When true, disables the button and shows an inline spinner — use while an
+ /// async action is in flight. Defaults to false.
+ ///
[Parameter] public bool Busy { get; set; }
+
+ /// Button label content.
[Parameter] public RenderFragment? ChildContent { get; set; }
+
+ ///
+ /// Arbitrary additional HTML attributes splatted onto the underlying
+ /// <button> (e.g. id, @onclick, name, value).
+ ///
[Parameter(CaptureUnmatchedValues = true)] public IDictionary? AdditionalAttributes { get; set; }
private string VariantClass => Variant switch
diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/TechCard.razor b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/TechCard.razor
index 8277737..5c8d6b9 100644
--- a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/TechCard.razor
+++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/TechCard.razor
@@ -8,9 +8,23 @@
@code {
+ ///
+ /// Optional string title rendered in the panel header. Ignored when
+ /// is supplied ( takes precedence).
+ ///
[Parameter] public string? Title { get; set; }
+
+ ///
+ /// Optional custom panel header content. Takes precedence over .
+ ///
[Parameter] public RenderFragment? Header { get; set; }
+
+ /// Panel body content.
[Parameter] public RenderFragment? ChildContent { get; set; }
+
+ /// Optional panel footer content (padded, top-bordered).
[Parameter] public RenderFragment? Footer { get; set; }
+
+ /// Additional CSS classes appended to the root <section class="panel">.
[Parameter] public string? Class { get; set; }
}
diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/TechField.razor b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/TechField.razor
index 0ba0d55..5da4c29 100644
--- a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/TechField.razor
+++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/TechField.razor
@@ -8,8 +8,17 @@
@code {
+ /// Field label text. Required.
[Parameter, EditorRequired] public string Label { get; set; } = string.Empty;
+
+ /// Optional hint text rendered as .form-text below the input.
[Parameter] public string? Hint { get; set; }
+
+ ///
+ /// Optional inline error message rendered as .field-error.s-bad below the input.
+ ///
[Parameter] public string? Error { get; set; }
+
+ /// The control itself — an <input>, <select>, etc.
[Parameter] public RenderFragment? ChildContent { get; set; }
}
diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/ThemeShell.razor b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/ThemeShell.razor
index 0e4977b..d2973a7 100644
--- a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/ThemeShell.razor
+++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/ThemeShell.razor
@@ -1,13 +1,15 @@
@* Components/ThemeShell.razor — the one canonical side-rail chassis.
- Not a LayoutComponentBase: the app's thin MainLayout delegates to this. *@
+ Not a LayoutComponentBase: the app's thin MainLayout delegates to this.
+ The narrow-viewport hamburger uses a CSS-only disclosure (no
+ Bootstrap JS): the rail is force-shown on lg+ and toggles via the native
+ open state below lg, so it works in pure static SSR. *@
@namespace ZB.MOM.WW.Theme
-
-
-
+
+
@ChildContent
-
+
@code {
+ ///
+ /// Product name shown in the rail's header. Required.
+ ///
[Parameter, EditorRequired] public string Product { get; set; } = string.Empty;
+
+ ///
+ /// Optional per-app accent colour. When set, emitted as style="--accent: <value>"
+ /// on the shell root, overriding the --accent design token for this subtree only.
+ /// This is the single per-app token override the kit allows (e.g. "#2f855a").
+ ///
[Parameter] public string? Accent { get; set; }
+
+ ///
+ /// Optional custom logo rendered in the rail header. When omitted, the default
+ /// brand glyph is shown.
+ ///
[Parameter] public RenderFragment? Logo { get; set; }
+
+ ///
+ /// Rail navigation content — typically /
+ /// elements.
+ ///
[Parameter] public RenderFragment? Nav { get; set; }
+
+ ///
+ /// Optional content pinned to the bottom of the rail (e.g. session info / sign-out).
+ ///
[Parameter] public RenderFragment? RailFooter { get; set; }
+
+ ///
+ /// The page body — normally @Body forwarded from the app's MainLayout.
+ ///
[Parameter] public RenderFragment? ChildContent { get; set; }
private string? AccentStyle => Accent is null ? null : $"--accent: {Accent}";
diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/StatusState.cs b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/StatusState.cs
index c39b646..3fce878 100644
--- a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/StatusState.cs
+++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/StatusState.cs
@@ -1,3 +1,23 @@
namespace ZB.MOM.WW.Theme;
-public enum StatusState { Ok, Warn, Bad, Idle, Info }
+///
+/// Status conveyed by a StatusPill chip, mapped to a token-based
+/// .chip-* class (status is communicated by colour, not iconography).
+///
+public enum StatusState
+{
+ /// Success / healthy / connected — emits chip-ok (green).
+ Ok,
+
+ /// Warning / degraded — emits chip-warn (amber).
+ Warn,
+
+ /// Error / faulted / disconnected — emits chip-bad (red).
+ Bad,
+
+ /// Unknown / offline / neutral — emits chip-idle (grey). The default.
+ Idle,
+
+ /// Informational — emits chip-info (on-palette blue).
+ Info,
+}
diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/ZB.MOM.WW.Theme.csproj b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/ZB.MOM.WW.Theme.csproj
index 5362dc5..9376b32 100644
--- a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/ZB.MOM.WW.Theme.csproj
+++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/ZB.MOM.WW.Theme.csproj
@@ -2,6 +2,13 @@
ZB.MOM.WW.Themetrue
+
+ true
+ $(NoWarn);CS1591trueZB.MOM.WW.ThemeShared Technical-Light UI kit (tokens, fonts, side-rail shell, widgets) for the ZB.MOM.WW SCADA family.
diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/css/layout.css b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/css/layout.css
index c155f43..bb74c69 100644
--- a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/css/layout.css
+++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/css/layout.css
@@ -2,9 +2,13 @@
Tokens live in theme.css; this sheet carries only layout + the side rail. */
/* ── App shell: side rail + page ─────────────────────────────────────────── */
-/* The outer flex direction is supplied by Bootstrap utilities on the wrapper
- (`d-flex flex-column flex-lg-row`) so the mobile hamburger row stacks above
- the rail on disclosure so the narrow-viewport hamburger
+ toggle works with NO JavaScript (no Bootstrap collapse bundle): below lg the
+ hamburger opens/closes the rail; on lg+ the rail is force-shown
+ regardless of the open state (see the lg media query below). The outer flex
+ direction is supplied by Bootstrap utilities on the wrapper
+ (`d-flex flex-column flex-lg-row`) so the hamburger row stacks above the rail
+ on : hide the native disclosure triangle so it reads as a
+ plain button (the styling comes from the Bootstrap .btn utilities). */
+.app-shell > summary.rail-toggle {
+ list-style: none;
+ cursor: pointer;
+}
+.app-shell > summary.rail-toggle::-webkit-details-marker { display: none; }
+
+/* Below lg the native shows/hides #theme-rail via [open]; nothing
+ extra is needed there. On lg+ we force the rail visible (see media query)
+ and keep the toggle hidden via Bootstrap's d-lg-none. */
+
/* ── Side rail ───────────────────────────────────────────────────────────── */
.side-rail {
width: 220px;
@@ -27,9 +43,12 @@
border-right: 1px solid var(--rule-strong);
}
-/* On lg+ keep the side rail pinned so it stays visible when content scrolls. */
+/* On lg+ keep the side rail pinned so it stays visible when content scrolls,
+ and force it shown regardless of the open state (the hamburger
+ toggle is hidden at this width). */
@media (min-width: 992px) {
#theme-rail {
+ display: block;
position: sticky;
top: 0;
height: 100vh;
@@ -38,8 +57,9 @@
}
}
-/* When the side rail is collapsed under is closed)
+ the native disclosure hides #theme-rail; when open, restore full width on
+ mobile so the rail spans the viewport. */
@media (max-width: 991.98px) {
.side-rail {
width: 100%;
@@ -72,17 +92,9 @@
}
.side-rail .brand .mark { color: var(--accent); }
-.rail-eyebrow {
- font-size: 0.68rem;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.07em;
- color: var(--ink-faint);
- padding: 0.3rem 0.6rem;
-}
-
-/* Collapsible variant — rendered by NavRailSection. Looks like .rail-eyebrow
- plus a leading chevron; clicking flips chevron + expanded state. */
+/* Section eyebrow — the label of a NavRailSection. The collapse
+ chevron is supplied by the `.rail-section > summary::before` rule below; this
+ rule styles the uppercase eyebrow text itself. */
.rail-eyebrow-toggle {
display: flex;
align-items: center;
@@ -100,12 +112,6 @@
cursor: pointer;
}
.rail-eyebrow-toggle:hover { color: var(--ink); }
-.rail-eyebrow-chevron {
- display: inline-block;
- width: 0.7rem;
- font-size: 0.55rem;
- color: var(--ink-faint);
-}
.rail-section-body {
display: flex;
flex-direction: column;
@@ -120,17 +126,28 @@
color: var(--ink-soft);
}
.rail-link:hover {
- background: #f3f6fd;
+ background: var(--hover-bg);
color: var(--ink);
text-decoration: none;
}
.rail-link.active {
- background: #eef2fc;
+ background: var(--active-bg);
border-left-color: var(--accent);
color: var(--accent-deep);
font-weight: 600;
}
+/* Optional leading icon inside a rail link (NavRailItem's Icon slot). Sizes and
+ aligns the icon and gives it a gap from the label text. */
+.rail-ico {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1rem;
+ margin-right: 0.4rem;
+ color: var(--ink-faint);
+}
+
/* ── Session block, pinned to the rail foot ──────────────────────────────── */
.rail-foot {
margin-top: auto;
@@ -180,8 +197,8 @@
.rail-section > summary::before { content: '\25B6'; font-size: 0.55rem; color: var(--ink-faint); margin-right: 0.4rem; }
.rail-section[open] > summary::before { content: '\25BC'; }
-/* StatusPill: info variant (on-palette, reuses dir-read colours) */
-.chip-info { color: var(--accent-deep); background: #e7ecfb; border-color: #cdd9f7; }
+/* StatusPill: info variant (on-palette, reuses the info blue wash) */
+.chip-info { color: var(--accent-deep); background: var(--info-bg); border-color: var(--info-border); }
/* TechCard body/footer padding; TechField error; LoginCard body */
.panel-body { padding: 0.85rem 0.9rem; }
diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/css/theme.css b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/css/theme.css
index 53fb793..b662f94 100644
--- a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/css/theme.css
+++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/css/theme.css
@@ -62,6 +62,29 @@
--bad-bg: #fceaea;
--idle-bg: #eef0f2;
+ /* Status — derived border tints (lighter than the foreground; for chips,
+ pills, and tinted cards). Pair with the matching -bg above. */
+ --ok-border: #c6e6cd;
+ --warn-border: #efd6a6;
+ --bad-border: #eec3c3;
+ --ok-border-soft: #bfe3c6; /* conn pill (slightly lighter) */
+ --warn-border-soft: #f0d9ab; /* conn pill (slightly lighter) */
+ --bad-border-soft: #f0c0c0; /* conn pill (slightly lighter) */
+
+ /* Warning ink — a deeper amber than --warn, for chip/notice/value text that
+ needs more contrast on the warm tint than the pure --warn would give. */
+ --warn-ink: #b56a00;
+ --warn-ink-deep: #8a5a00;
+
+ /* Info / accent tint — on-palette blue wash (StatusPill Info, dir-read tag) */
+ --info-bg: #e7ecfb;
+ --info-border: #cdd9f7;
+
+ /* Neutral surface washes — derived from --paper/--accent, no own foreground */
+ --zebra-bg: #fbfbf9; /* even-row / sticky-head fill — barely-there */
+ --hover-bg: #f3f6fd; /* row / rail-link hover — faint cool wash */
+ --active-bg: #eef2fc; /* active rail-link fill */
+
/* Type stacks — Plex first, graceful system fallback */
--mono: 'IBM Plex Mono', ui-monospace, 'Cascadia Mono', Consolas, monospace;
--sans: 'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', sans-serif;
@@ -81,7 +104,7 @@
not a gradient wash. Keep it subtle. */
body {
background:
- radial-gradient(1200px 480px at 88% -8%, #ffffff 0%, rgba(255,255,255,0) 70%),
+ radial-gradient(1200px 480px at 88% -8%, var(--card) 0%, rgba(255,255,255,0) 70%),
var(--paper);
color: var(--ink);
font-family: var(--sans);
@@ -142,11 +165,11 @@ a:hover { color: var(--accent-deep); text-decoration: underline; }
width: 7px; height: 7px; border-radius: 50%;
background: var(--idle);
}
-.conn-pill[data-state="connected"] { color: var(--ok); border-color: #bfe3c6; background: var(--ok-bg); }
+.conn-pill[data-state="connected"] { color: var(--ok); border-color: var(--ok-border-soft); background: var(--ok-bg); }
.conn-pill[data-state="connected"] .dot { background: var(--ok); }
-.conn-pill[data-state="connecting"] { color: var(--warn); border-color: #f0d9ab; background: var(--warn-bg); }
+.conn-pill[data-state="connecting"] { color: var(--warn); border-color: var(--warn-border-soft); background: var(--warn-bg); }
.conn-pill[data-state="connecting"] .dot { background: var(--warn); animation: pulse 1.1s ease-in-out infinite; }
-.conn-pill[data-state="disconnected"] { color: var(--bad); border-color: #f0c0c0; background: var(--bad-bg); }
+.conn-pill[data-state="disconnected"] { color: var(--bad); border-color: var(--bad-border-soft); background: var(--bad-bg); }
.conn-pill[data-state="disconnected"] .dot { background: var(--bad); }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.25; } }
@@ -171,10 +194,10 @@ a:hover { color: var(--accent-deep); text-decoration: underline; }
border-radius: 4px;
border: 1px solid transparent;
}
-.chip-ok { color: var(--ok); background: var(--ok-bg); border-color: #c6e6cd; }
-.chip-warn { color: #b56a00; background: var(--warn-bg); border-color: #efd6a6; }
-.chip-bad { color: var(--bad); background: var(--bad-bg); border-color: #eec3c3; }
-.chip-idle { color: var(--ink-soft); background: var(--idle-bg); border-color: var(--rule-strong); }
+.chip-ok { color: var(--ok); background: var(--ok-bg); border-color: var(--ok-border); }
+.chip-warn { color: var(--warn-ink); background: var(--warn-bg); border-color: var(--warn-border); }
+.chip-bad { color: var(--bad); background: var(--bad-bg); border-color: var(--bad-border); }
+.chip-idle { color: var(--idle); background: var(--idle-bg); border-color: var(--rule-strong); }
/* ── Panel — the base raised surface ─────────────────────────────────────────
A white card with a hairline border and 8px radius. .panel-head is the
@@ -248,10 +271,10 @@ a:hover { color: var(--accent-deep); text-decoration: underline; }
font-weight: 400;
color: var(--ink-faint);
}
-.agg-card.alert { border-color: #eec3c3; background: var(--bad-bg); }
+.agg-card.alert { border-color: var(--bad-border); background: var(--bad-bg); }
.agg-card.alert .agg-value { color: var(--bad); }
-.agg-card.caution { border-color: #efd6a6; background: var(--warn-bg); }
-.agg-card.caution .agg-value { color: #b56a00; }
+.agg-card.caution { border-color: var(--warn-border); background: var(--warn-bg); }
+.agg-card.caution .agg-value { color: var(--warn-ink); }
/* ── Metric card + key/value rows ────────────────────────────────────────────
A .panel-head over a stack of .kv rows: label left, monospace value right.
@@ -278,7 +301,7 @@ a:hover { color: var(--accent-deep); text-decoration: underline; }
padding: 0.32rem 0.9rem;
font-size: 0.85rem;
}
-.kv:nth-child(even) { background: #fbfbf9; }
+.kv:nth-child(even) { background: var(--zebra-bg); }
.kv .k { color: var(--ink-soft); }
.kv .v {
font-family: var(--mono);
@@ -332,7 +355,7 @@ a:hover { color: var(--accent-deep); text-decoration: underline; }
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ink-faint);
- background: #fbfbf9;
+ background: var(--zebra-bg);
position: sticky;
top: 0;
}
@@ -345,7 +368,7 @@ a:hover { color: var(--accent-deep); text-decoration: underline; }
.data-table th.sorted-desc::after { content: ' \2193'; color: var(--accent); }
.data-table tbody tr { cursor: pointer; transition: background 0.08s; }
-.data-table tbody tr:hover { background: #f3f6fd; }
+.data-table tbody tr:hover { background: var(--hover-bg); }
.data-table tbody tr:last-child td { border-bottom: none; }
.empty-row {
@@ -365,15 +388,15 @@ a:hover { color: var(--accent-deep); text-decoration: underline; }
padding: 0.1rem 0.4rem;
border-radius: 3px;
}
-.dir-read { color: var(--accent-deep); background: #e7ecfb; }
-.dir-write { color: #8a5a00; background: var(--warn-bg); }
+.dir-read { color: var(--accent-deep); background: var(--info-bg); }
+.dir-write { color: var(--warn-ink-deep); background: var(--warn-bg); }
/* ── Inline notice ───────────────────────────────────────────────────────────
A .panel with a warning tint — for "this thing is gone / degraded" banners. */
.notice {
padding: 0.85rem 1.1rem;
margin-bottom: 1rem;
- color: #b56a00;
+ color: var(--warn-ink);
background: var(--warn-bg);
- border-color: #efd6a6;
+ border-color: var(--warn-border);
}
diff --git a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/NavRailTests.cs b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/NavRailTests.cs
index 479a05b..2d6eddd 100644
--- a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/NavRailTests.cs
+++ b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/NavRailTests.cs
@@ -13,6 +13,28 @@ public class NavRailTests : TestContext
Assert.Contains("Clusters", a.TextContent);
}
+ // Theme-004: when Icon is supplied it is wrapped in a .rail-ico span (now styled).
+ [Fact]
+ public void NavRailItem_wraps_icon_in_rail_ico_span_when_supplied()
+ {
+ var cut = RenderComponent(p => p
+ .Add(x => x.Href, "/clusters")
+ .Add(x => x.Text, "Clusters")
+ .Add(x => x.Icon, (RenderFragment)(b => b.AddMarkupContent(0, ""))));
+ var ico = cut.Find("a.rail-link .rail-ico");
+ Assert.NotNull(ico);
+ Assert.NotNull(cut.Find("a.rail-link .rail-ico .ico"));
+ }
+
+ [Fact]
+ public void NavRailItem_omits_rail_ico_span_when_no_icon()
+ {
+ var cut = RenderComponent(p => p
+ .Add(x => x.Href, "/clusters")
+ .Add(x => x.Text, "Clusters"));
+ Assert.Empty(cut.FindAll(".rail-ico"));
+ }
+
[Fact]
public void NavRailSection_renders_title_and_children_open_by_default()
{
diff --git a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/StaticAssetsTests.cs b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/StaticAssetsTests.cs
index 2bc89ec..73b3da2 100644
--- a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/StaticAssetsTests.cs
+++ b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/StaticAssetsTests.cs
@@ -1,4 +1,6 @@
using System.IO;
+using System.Linq;
+using System.Text.RegularExpressions;
namespace ZB.MOM.WW.Theme.Tests;
@@ -32,4 +34,36 @@ public class StaticAssetsTests
[InlineData("ibm-plex-mono-500.woff2")]
public void Fonts_are_vendored(string file) =>
Assert.True(File.Exists(Path.Combine(Wwwroot, "fonts", file)));
+
+ // Theme-002: .chip-idle pairs the idle background with the matching --idle
+ // foreground token (per DESIGN-TOKENS.md), not --ink-soft.
+ [Fact]
+ public void ChipIdle_pairs_idle_foreground_with_idle_background()
+ {
+ var css = File.ReadAllText(Path.Combine(Wwwroot, "css", "theme.css"));
+ var rule = Regex.Match(css, @"\.chip-idle\s*\{[^}]*\}").Value;
+ Assert.Contains("color: var(--idle)", rule);
+ Assert.Contains("background: var(--idle-bg)", rule);
+ Assert.DoesNotContain("--ink-soft", rule);
+ }
+
+ // Theme-003: no hardcoded hex colours appear outside the :root token block in
+ // either stylesheet — every shade is a named token.
+ [Theory]
+ [InlineData("theme.css")]
+ [InlineData("layout.css")]
+ public void No_hardcoded_hex_outside_root_token_block(string file)
+ {
+ var css = File.ReadAllText(Path.Combine(Wwwroot, "css", file));
+
+ // Strip the :root { ... } declaration block(s) — the one place hex literals live.
+ var withoutRoot = Regex.Replace(css, @":root\s*\{[^}]*\}", string.Empty);
+
+ var hexLiterals = Regex.Matches(withoutRoot, @"#[0-9a-fA-F]{3,8}\b")
+ .Select(m => m.Value)
+ .ToList();
+
+ Assert.True(hexLiterals.Count == 0,
+ $"{file} has hardcoded hex outside :root: {string.Join(", ", hexLiterals)}");
+ }
}
diff --git a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/ThemeShellTests.cs b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/ThemeShellTests.cs
index cb8c6c8..67f282f 100644
--- a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/ThemeShellTests.cs
+++ b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/ThemeShellTests.cs
@@ -40,4 +40,26 @@ public class ThemeShellTests : TestContext
.Add(x => x.RailFooter, (RenderFragment)(b => b.AddMarkupContent(0, "S"))));
Assert.NotNull(cut.Find(".rail-foot .sess"));
}
+
+ // Theme-001: the mobile hamburger must work without Bootstrap collapse JS.
+ // The shell is a native disclosure whose is the toggle —
+ // no data-bs-toggle / data-bs-target attributes, so no Bootstrap JS dependency.
+ [Fact]
+ public void Mobile_toggle_is_a_css_only_details_disclosure_not_bootstrap_collapse()
+ {
+ var cut = RenderComponent(p => p.Add(x => x.Product, "OtOpcUa"));
+
+ // Root is a whose direct is the hamburger toggle.
+ var shell = cut.Find("details.app-shell");
+ var summary = cut.Find("details.app-shell > summary.rail-toggle");
+ Assert.NotNull(summary);
+
+ // The rail is plain markup inside the — no Bootstrap .collapse class.
+ var rail = cut.Find("#theme-rail");
+ Assert.DoesNotContain("collapse", rail.ClassList);
+
+ // No Bootstrap collapse JS hooks anywhere in the shell markup.
+ Assert.DoesNotContain("data-bs-toggle", shell.OuterHtml);
+ Assert.DoesNotContain("data-bs-target", shell.OuterHtml);
+ }
}
diff --git a/components/observability/shared-contract/ZB.MOM.WW.Telemetry.md b/components/observability/shared-contract/ZB.MOM.WW.Telemetry.md
index bd5c6ff..1ebfe67 100644
--- a/components/observability/shared-contract/ZB.MOM.WW.Telemetry.md
+++ b/components/observability/shared-contract/ZB.MOM.WW.Telemetry.md
@@ -124,17 +124,32 @@ public static class ZbTelemetryExtensions
/// Used internally by AddZbTelemetry. Exposed for tests and custom pipelines.
public static class ZbResource
{
+ /// Deterministic, process-stable service instance identifier, formatted as
+ /// "MachineName:ProcessId". Populates OTel Resource service.instance.id on every signal
+ /// (metrics, traces, logs). The OTel SDK's random-GUID default is disabled in favour of this
+ /// value so all signals from one process share one restart-stable instance id, enabling
+ /// cross-signal correlation. Always present (not optional).
+ public static string InstanceId { get; }
+
/// Returns a ResourceBuilder pre-populated with service.name, service.namespace,
- /// service.version, site.id, node.role, and host.name (always Environment.MachineName).
- /// Attributes with null values are omitted from the Resource.
+ /// service.version, service.instance.id (always — see InstanceId), site.id, node.role, and
+ /// host.name (always Environment.MachineName). Attributes with null/empty values are omitted.
public static ResourceBuilder Build(ZbTelemetryOptions options);
+
+ /// The single source of truth for the shared Resource attribute set. Both the OTel SDK
+ /// metrics/traces pipeline and the Serilog OTLP log sink derive their attributes from this one
+ /// map, so logs cannot drift from metrics/traces. service.name/namespace/instance.id/host.name
+ /// are always present; service.version/site.id/node.role are included only when the option is
+ /// non-null/non-empty.
+ public static IReadOnlyDictionary BuildAttributes(ZbTelemetryOptions options);
}
/// Endpoint extension for mounting the Prometheus /metrics scrape endpoint.
public static class ZbMetricsEndpointExtensions
{
- /// Mounts the Prometheus /metrics endpoint.
- /// Only valid when ZbTelemetryOptions.Exporter = ZbExporter.Prometheus (or both).
+ /// Mounts the Prometheus /metrics endpoint. Valid under ANY ZbTelemetryOptions.Exporter value:
+ /// AddZbTelemetry always wires the Prometheus exporter, and OTLP (ZbExporter.Otlp) is only an
+ /// additive overlay — so /metrics serves scrape data even when Exporter = ZbExporter.Otlp.
/// Call after app.UseRouting().
public static IEndpointConventionBuilder MapZbMetrics(
this IEndpointRouteBuilder endpoints);
diff --git a/components/observability/spec/METRIC-CONVENTIONS.md b/components/observability/spec/METRIC-CONVENTIONS.md
index 077aa86..0e6751c 100644
--- a/components/observability/spec/METRIC-CONVENTIONS.md
+++ b/components/observability/spec/METRIC-CONVENTIONS.md
@@ -104,6 +104,7 @@ joinable in any OTel-compatible backend.
| `service.name` | string | Yes | Short lower-case app id: `otopcua`, `mxgateway`, `scadabridge` |
| `service.namespace` | string | Yes | Always `"ZB.MOM.WW"` — do not override |
| `service.version` | string | Recommended | Populate from `AssemblyInformationalVersion`; absent is better than wrong |
+| `service.instance.id` | string | Auto | Always `ZbResource.InstanceId` = deterministic `MachineName:ProcessId`. The OTel SDK random-GUID default is disabled so every signal from one process shares one restart-stable instance id (cross-signal correlation); never override |
| `site.id` | string | Recommended | Physical or logical site identifier; omit for single-site deployments |
| `node.role` | string | Recommended | Node function: `"central"`, `"site"`, `"hub"`, `"standalone"` |
| `host.name` | string | Auto | Always `Environment.MachineName`; never override |
diff --git a/components/observability/spec/SPEC.md b/components/observability/spec/SPEC.md
index 208cc9b..b54e12f 100644
--- a/components/observability/spec/SPEC.md
+++ b/components/observability/spec/SPEC.md
@@ -68,6 +68,7 @@ The OTel `Resource` attached to all three signals is built from `ZbTelemetryOpti
| `service.name` | `ServiceName` | Required. Lower-case short identifier (`otopcua`, `mxgateway`, `scadabridge`) |
| `service.namespace` | `ServiceNamespace` | Default `"ZB.MOM.WW"` — constant across the fleet |
| `service.version` | `ServiceVersion` | Optional; recommend populating from `AssemblyInformationalVersion` |
+| `service.instance.id` | _(auto)_ | Always populated from `ZbResource.InstanceId` — a deterministic `MachineName:ProcessId`. The OTel SDK's random-GUID default is disabled so every signal from the same process carries an identical, restart-stable instance id for cross-signal correlation |
| `site.id` | `SiteId` | Optional; identifies the physical/logical site |
| `node.role` | `NodeRole` | Optional; e.g. `"central"`, `"site"`, `"hub"` |
| `host.name` | _(auto)_ | Always populated from `Environment.MachineName` |