Fix all baseline code-review findings across the six shared libraries

Resolves the 35 findings from the 2026-06-01 baseline (commit 26ba1c7),
test-first for every behavioral change. +51 tests (331 -> 382 passing, 0 failed).

- Telemetry-001 (HIGH): RedactionEnricher now honours property removal, so a
  redactor that drops a key actually scrubs the secret from the event.
- Auth: LDAP validator ValidateOnStart; API-key verify no longer fails on a
  best-effort MarkUsed write or a corrupt scopes column (fail-closed); LDAP cert
  validation hook; KeyPrefix persistence aligned; README algorithm corrected.
- Health: Akka checks return Degraded (not throw) when the cluster isn't up yet;
  GrpcDependencyHealthCheck catch-all; null 'description' rendered; composite
  endpoint builder; XML docs shipped.
- Audit: CompositeAuditWriter no longer re-throws OperationCanceledException;
  TruncatingAuditRedactor over-redact scrubs Target + safe negative max; options
  record; XML docs shipped.
- Configuration: TryAddEnumerable idempotent registration; consistent port
  quoting; strict invariant port parsing; XML docs + README packaged.
- Theme: mobile toggle is now CSS-only (no Bootstrap JS); token/CSS hygiene;
  XML docs on the public parameter surface.

Shared-contract/spec docs updated where the code was the source of truth
(observability service.instance.id, MapZbMetrics, redactor reach). All changes
additive/back-compatible at v0.1.0. code-reviews bookkeeping follows separately.
This commit is contained in:
Joseph Doherty
2026-06-01 11:22:14 -04:00
parent 26ba1c7215
commit 544a6ddb77
72 changed files with 1539 additions and 191 deletions
@@ -1,8 +1,9 @@
namespace ZB.MOM.WW.Audit;
/// <summary>Fans an event out to several writers. Best-effort: a failing writer does not stop the others.</summary>
/// <remarks>A failing writer's exception is swallowed so the fan-out drains and the caller is never
/// aborted — but <see cref="OperationCanceledException"/> is re-thrown so cancellation is honored.</remarks>
/// <remarks>Every inner-writer failure is swallowed — including <see cref="OperationCanceledException"/>
/// — so the fan-out drains and the caller is never aborted, honoring the <see cref="IAuditWriter"/>
/// "must not throw to the caller" contract even when a request-scoped cancellation token is passed.</remarks>
public sealed class CompositeAuditWriter : IAuditWriter
{
private readonly IReadOnlyList<IAuditWriter> _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 */ }
}
}
}
@@ -2,8 +2,9 @@ namespace ZB.MOM.WW.Audit;
/// <summary>
/// Redactor that caps oversized <see cref="AuditEvent.DetailsJson"/> and <see cref="AuditEvent.Target"/>.
/// 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.
/// </summary>
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];
@@ -1,12 +1,12 @@
namespace ZB.MOM.WW.Audit;
/// <summary>Caps for <see cref="TruncatingAuditRedactor"/>.</summary>
public sealed class TruncatingAuditRedactorOptions
/// <summary>Immutable caps for <see cref="TruncatingAuditRedactor"/>.</summary>
public sealed record TruncatingAuditRedactorOptions
{
/// <summary>Max length of <see cref="AuditEvent.DetailsJson"/> before truncation. Default 4096.</summary>
public int MaxDetailsJsonLength { get; set; } = 4096;
public int MaxDetailsJsonLength { get; init; } = 4096;
/// <summary>Max length of <see cref="AuditEvent.Target"/> before truncation. Default 512.</summary>
public int MaxTargetLength { get; set; } = 512;
public int MaxTargetLength { get; init; } = 512;
/// <summary>Marker appended to a truncated value. Default "…[truncated]".</summary>
public string TruncationMarker { get; set; } = "…[truncated]";
public string TruncationMarker { get; init; } = "…[truncated]";
}
@@ -3,6 +3,8 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- Emit and pack XML docs so consumers get IntelliSense/tooltip documentation. -->
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<PropertyGroup>
<IsPackable>true</IsPackable>
@@ -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<OperationCanceledException>(() => 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<IAuditWriter>());
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);
}
}
@@ -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);
}
}
+2 -2
View File
@@ -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` |
---
@@ -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;
/// <summary>
/// Optional hook to harden (or, in dev, relax) TLS server-certificate validation for the
/// <see cref="LdapTransport.Ldaps"/> / <see cref="LdapTransport.StartTls"/> transports. When
/// <see langword="null"/> (the default) the LDAP client validates the server certificate against
/// the OS trust store — it does <em>not</em> blind-accept. Supply a callback to pin a CA, validate
/// the SAN against <see cref="Server"/>, or otherwise tighten validation. This is a code-only seam
/// (not bound from configuration) and takes precedence over <see cref="AllowInsecure"/>.
/// </summary>
public RemoteCertificateValidationCallback? ServerCertificateValidationCallback { get; init; }
}
public enum LdapAuthFailure { BadCredentials, UserNotFound, AmbiguousUser, GroupLookupFailed, ServiceAccountBindFailed, Disabled }
@@ -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,
@@ -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,
@@ -20,7 +20,12 @@ public static class ScopeSerializer
/// <summary>Deserializes scopes from a JSON array string.</summary>
/// <param name="value">The JSON string to deserialize; may be null or empty.</param>
/// <returns>An ordinal-compared set of scopes; empty when the input is null/blank.</returns>
/// <returns>
/// 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 <see cref="JsonException"/>.
/// </returns>
public static IReadOnlySet<string> Deserialize(string? value)
{
if (string.IsNullOrWhiteSpace(value))
@@ -28,7 +33,18 @@ public static class ScopeSerializer
return new HashSet<string>(StringComparer.Ordinal);
}
string[]? scopes = JsonSerializer.Deserialize<string[]>(value);
string[]? scopes;
try
{
scopes = JsonSerializer.Deserialize<string[]>(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<string>(StringComparer.Ordinal);
}
return new HashSet<string>(scopes ?? [], StringComparer.Ordinal);
}
@@ -37,7 +37,14 @@ public static class ServiceCollectionExtensions
ArgumentNullException.ThrowIfNull(config);
ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath);
services.Configure<LdapOptions>(config.GetSection(sectionPath));
// Bind via the options builder and opt into start-time validation. An IValidateOptions<T>
// otherwise only runs when the options are first materialized (IOptions<T>.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<LdapOptions>()
.Bind(config.GetSection(sectionPath))
.ValidateOnStart();
// Fail fast at startup on a misconfigured directory rather than on first login.
services.AddSingleton<IValidateOptions<LdapOptions>, LdapOptionsValidator>();
@@ -1,5 +1,6 @@
namespace ZB.MOM.WW.Auth.Ldap.Internal;
using System.Net.Security;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
/// <summary>
@@ -15,8 +16,29 @@ internal sealed record LdapSearchEntry(
/// </summary>
internal interface ILdapConnection : IDisposable
{
/// <summary>Opens (and optionally upgrades to TLS) a connection to the given host.</summary>
void Connect(string host, int port, LdapTransport transport, bool allowInsecure, int timeoutMs);
/// <summary>
/// Opens (and optionally upgrades to TLS) a connection to the given host.
/// </summary>
/// <param name="host">The LDAP server hostname or IP.</param>
/// <param name="port">The LDAP server port.</param>
/// <param name="transport">The transport security mode.</param>
/// <param name="allowInsecure">
/// When <see langword="true"/> AND no <paramref name="serverCertificateValidationCallback"/> 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.
/// </param>
/// <param name="timeoutMs">The connection/operation timeout in milliseconds.</param>
/// <param name="serverCertificateValidationCallback">
/// Optional TLS server-certificate validation callback. When <see langword="null"/>, the OS trust
/// store is used (the client does not blind-accept).
/// </param>
void Connect(
string host,
int port,
LdapTransport transport,
bool allowInsecure,
int timeoutMs,
RemoteCertificateValidationCallback? serverCertificateValidationCallback);
/// <summary>Binds with the supplied DN and password. Throws <c>LdapException</c> on bad credentials.</summary>
void Bind(string dn, string password);
@@ -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;
/// <summary>
/// Production <see cref="ILdapConnection"/> backed by <c>Novell.Directory.Ldap.LdapConnection</c>.
/// Mirrors the connection/search idioms from ZB.MOM.WW.ScadaBridge.Security.LdapAuthService.
/// </summary>
/// <remarks>
/// TLS server-certificate validation: by default the underlying
/// <c>Novell.Directory.Ldap.NETStandard</c> client validates the server certificate against the OS
/// trust store (it does NOT blind-accept). A caller-supplied
/// <c>RemoteCertificateValidationCallback</c> overrides that default (CA pinning / SAN checks); when
/// none is supplied and <c>allowInsecure</c> is set, validation is bypassed for dev/test only.
/// </remarks>
internal sealed class NovellLdapConnection : ILdapConnection
{
private readonly LdapConnection _conn = new();
private readonly LdapConnection _conn;
private bool _disposed;
/// <inheritdoc/>
public void Connect(string host, int port, LdapTransport transport, bool allowInsecure, int timeoutMs)
/// <summary>
/// Builds the connection, wiring a TLS server-certificate validation policy: a supplied
/// <paramref name="serverCertificateValidationCallback"/> wins; otherwise <paramref name="allowInsecure"/>
/// bypasses validation (dev/test only); otherwise the OS-trust-store default applies.
/// </summary>
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();
}
}
/// <inheritdoc/>
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
}
}
/// <summary>Factory that produces fresh <see cref="NovellLdapConnection"/> instances.</summary>
internal sealed class NovellLdapConnectionFactory : ILdapConnectionFactory
/// <summary>
/// Factory that produces fresh <see cref="NovellLdapConnection"/> instances, carrying the TLS
/// server-certificate validation policy (a supplied callback, or an <c>allowInsecure</c> bypass) so
/// it is wired onto each connection at construction time.
/// </summary>
internal sealed class NovellLdapConnectionFactory(
bool allowInsecure = false,
RemoteCertificateValidationCallback? serverCertificateValidationCallback = null)
: ILdapConnectionFactory
{
public ILdapConnection Create() => new NovellLdapConnection();
public ILdapConnection Create() =>
new NovellLdapConnection(allowInsecure, serverCertificateValidationCallback);
}
@@ -26,10 +26,14 @@ public sealed class LdapAuthService : ILdapAuthService
/// <summary>
/// Production constructor: binds against a live directory via the real
/// Novell-backed connection factory.
/// Novell-backed connection factory. The TLS server-certificate validation policy
/// (<see cref="LdapOptions.ServerCertificateValidationCallback"/> or the
/// <see cref="LdapOptions.AllowInsecure"/> bypass) is carried into the factory so each
/// connection is built with it.
/// </summary>
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)
{
@@ -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<string>(["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<ApiKeyListItem> 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()
{
@@ -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<OperationCanceledException>(
() => 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; }
/// <summary>When set, <see cref="MarkUsedAsync"/> throws this exception (after recording the call).</summary>
public Exception? MarkUsedException { get; set; }
public Task<ApiKeyRecord?> 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;
}
}
@@ -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<string> 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();
@@ -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<string, string?>
{
[$"{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<OptionsValidationException>(() => 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();
}
}
@@ -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; }
/// <summary>The server-certificate validation callback passed to the most recent <see cref="Connect"/> call.</summary>
public RemoteCertificateValidationCallback? ConnectCertCallback { get; private set; }
public List<string> BoundDns { get; } = new();
/// <summary>
@@ -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);
@@ -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()
{
@@ -6,5 +6,6 @@
<LangVersion>latest</LangVersion>
<Version>0.1.0</Version>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
</Project>
@@ -1,3 +1,5 @@
using System.Globalization;
namespace ZB.MOM.WW.Configuration;
/// <summary>
@@ -16,11 +18,14 @@ internal static class Checks
/// <summary>
/// Validates a raw string as a TCP port (parse + range), returning <c>null</c> 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 (<see cref="NumberStyles.None"/>): 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.
/// </summary>
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"}')";
/// <summary>
@@ -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;
@@ -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<IValidateOptions<TOptions>, TValidator>();
services.TryAddEnumerable(ServiceDescriptor.Singleton<IValidateOptions<TOptions>, TValidator>());
return services.AddOptions<TOptions>()
.Bind(configuration.GetSection(sectionPath))
.ValidateOnStart();
@@ -7,7 +7,11 @@
<PackageTags>configuration;options;validation;ivalidateoptions;validateonstart;startup;scada;wonderware;zb-mom-ww</PackageTags>
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-configuration</PackageProjectUrl>
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-configuration</RepositoryUrl>
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>
<ItemGroup>
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
@@ -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<string, string?> { ["Node:Port"] = "0", ["Node:Name"] = "" })
.Build();
var services = new ServiceCollection();
services.AddValidatedOptions<NodeOptions, NodeValidator>(config, "Node");
services.AddValidatedOptions<NodeOptions, NodeValidator>(config, "Node");
using var provider = services.BuildServiceProvider();
var validators = provider.GetServices<IValidateOptions<NodeOptions>>().ToArray();
Assert.Single(validators);
// Resolving the options surfaces each accumulated failure exactly once, not doubled.
var ex = Assert.Throws<OptionsValidationException>(
() => provider.GetRequiredService<IOptions<NodeOptions>>().Value);
Assert.Equal(2, ex.Failures.Count());
}
}
@@ -0,0 +1,92 @@
using Microsoft.Extensions.Configuration;
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.Configuration.Tests;
/// <summary>
/// Pins the exact failure-message wording produced by the shared <c>Checks</c> seam through its
/// public front-ends (<see cref="ConfigPreflight"/> for raw port values, <see cref="ValidationBuilder"/>
/// for host:port endpoints). Covers Configuration-002 (consistent quoting) and Configuration-003
/// (strict, culture-invariant port parsing).
/// </summary>
public sealed class ChecksWordingTests
{
private static IConfiguration Config(string key, string? value) =>
new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { [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);
}
}
@@ -1,6 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
<!-- Test project does not ship; no XML docs required (overrides Directory.Build.props). -->
<GenerateDocumentationFile>false</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
+5
View File
@@ -7,6 +7,11 @@
<LangVersion>latest</LangVersion>
<Version>0.1.0</Version>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<!-- Emit XML docs so the public API summaries ship inside the packed nupkgs (IntelliSense for
consumers). CS1591 (missing doc on a public member) is suppressed so undocumented test /
non-packed members do not break the build; the src public surface is fully documented. -->
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>
</Project>
@@ -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),
@@ -8,7 +8,8 @@ namespace ZB.MOM.WW.Health.Akka;
/// <summary>
/// Health check that maps the local node's Akka cluster membership status to a
/// <see cref="HealthStatus"/> through a configurable <see cref="AkkaClusterStatusPolicy"/>.
/// Register to the <see cref="ZbHealthTags.Ready"/> tag (recommended <c>[ready, active]</c>).
/// Register to the <see cref="ZbHealthTags.Ready"/> tag only — cluster membership is a readiness
/// concern; the <see cref="ZbHealthTags.Active"/> tier is reserved for the leader / active-node probe.
/// </summary>
/// <remarks>
/// The <see cref="ActorSystem"/> 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
@@ -13,14 +13,15 @@ namespace ZB.MOM.WW.Health;
/// The probe is injectable via <see cref="GrpcDependencyOptions.Probe"/>; the default drives the
/// channel to a connected state with <see cref="GrpcChannel.ConnectAsync"/>. The result is
/// <see cref="HealthStatus.Healthy"/> when the probe returns <c>true</c>, and
/// <see cref="HealthStatus.Unhealthy"/> when it returns <c>false</c>, throws an
/// <see cref="RpcException"/>, or times out / is cancelled within
/// <see cref="GrpcDependencyOptions.Timeout"/>.
/// <see cref="HealthStatus.Unhealthy"/> when it returns <c>false</c>, throws any exception
/// (<see cref="RpcException"/> or otherwise), or times out within
/// <see cref="GrpcDependencyOptions.Timeout"/>. External cancellation of the supplied
/// <see cref="CancellationToken"/> propagates as an <see cref="OperationCanceledException"/>.
/// </para>
/// <para>
/// Recommended registration tags: <see cref="ZbHealthTags.Ready"/> and
/// <see cref="ZbHealthTags.Active"/> — a missing downstream gRPC dependency makes the node both
/// not-ready and not-able-to-act. The registrant applies the tags.
/// Recommended registration tag: <see cref="ZbHealthTags.Ready"/> only — downstream gRPC
/// reachability is a readiness concern; the <see cref="ZbHealthTags.Active"/> tier is reserved for
/// the leader / active-node probe. The registrant applies the tag.
/// </para>
/// </remarks>
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);
}
}
/// <summary>
@@ -28,9 +28,9 @@ public static class ZbHealthEndpointExtensions
/// emits a minimal <c>200 OK</c> body.
/// </remarks>
/// <returns>
/// The <see cref="IEndpointConventionBuilder"/> for the readiness (<c>/health/ready</c>) 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 <see cref="IEndpointConventionBuilder"/> that fans every chained convention out to
/// <em>all three</em> health endpoints (readiness, active, and liveness). For example,
/// <c>endpoints.MapZbHealth().RequireHost("…")</c> gates all three endpoints, as a caller expects.
/// </returns>
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);
}
/// <summary>
/// An <see cref="IEndpointConventionBuilder"/> that forwards each convention to several
/// underlying builders, so conventions chained onto the result of
/// <see cref="MapZbHealth(IEndpointRouteBuilder, ZbHealthEndpointOptions?)"/> apply to all three
/// health endpoints rather than just one.
/// </summary>
private sealed class CompositeEndpointConventionBuilder : IEndpointConventionBuilder
{
private readonly IEndpointConventionBuilder[] _builders;
public CompositeEndpointConventionBuilder(params IEndpointConventionBuilder[] builders) =>
_builders = builders;
public void Add(Action<EndpointBuilder> convention)
{
foreach (var builder in _builders)
builder.Add(convention);
}
public void Finally(Action<EndpointBuilder> finalConvention)
{
foreach (var builder in _builders)
builder.Finally(finalConvention);
}
}
/// <summary>
@@ -70,7 +96,10 @@ public static class ZbHealthEndpointExtensions
/// </summary>
/// <param name="endpoints">The endpoint route builder to map onto.</param>
/// <param name="configure">Callback that mutates a fresh <see cref="ZbHealthEndpointOptions"/>.</param>
/// <returns>The <see cref="IEndpointConventionBuilder"/> for the readiness endpoint.</returns>
/// <returns>
/// A composite <see cref="IEndpointConventionBuilder"/> that fans chained conventions out to all
/// three health endpoints.
/// </returns>
public static IEndpointConventionBuilder MapZbHealth(
this IEndpointRouteBuilder endpoints,
Action<ZbHealthEndpointOptions> configure)
@@ -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;
/// }
/// }
/// </code>
/// 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 <c>Content-Type: application/json</c>.
/// The <c>description</c> key is always present; when a check supplies no description it is emitted
/// as JSON <c>null</c> (not omitted), matching the spec example and the <c>HealthChecks.UI.Client</c>
/// 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
/// <c>Content-Type: application/json</c>.
/// </remarks>
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.<name>.description without handling a missing
// property. (Do not set DefaultIgnoreCondition = WhenWritingNull here.)
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
/// <summary>
@@ -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),
@@ -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),
@@ -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()
{
@@ -31,7 +31,7 @@ public sealed class ResponseWriterTests
}
private static async Task<HttpResponseMessage> 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.<name>.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()
{
@@ -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()
{
+11 -3
View File
@@ -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** |
---
@@ -11,6 +11,20 @@ public interface ILogRedactor
/// <summary>
/// Inspects and mutates the supplied log-event <paramref name="properties"/> 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 <see cref="RedactionEnricher"/>.
/// <para>
/// <b>Reach — scalar top-level properties only.</b> Each entry's value is the unwrapped scalar
/// of a Serilog <c>ScalarValue</c> property (so simple string/number/etc. properties such as
/// <c>{apiKey}</c> can be read and masked directly). <b>Destructured / structured properties are
/// not unwrapped:</b> a <c>{@Object}</c> property arrives as the raw Serilog
/// <c>StructureValue</c> wrapper (and a sequence/dictionary as <c>SequenceValue</c>/
/// <c>DictionaryValue</c>). A redactor can therefore replace or remove the <i>whole</i>
/// top-level property, but it cannot reach a field <i>nested inside</i> 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.
/// </para>
/// </summary>
/// <param name="properties">The mutable property dictionary for the current log event.</param>
void Redact(IDictionary<string, object?> properties);
@@ -30,8 +30,18 @@ public sealed class RedactionEnricher : ILogEventEnricher
}
/// <summary>
/// Hands the log event's scalar properties to the registered <see cref="ILogRedactor"/> and
/// writes back any values the redactor changed. No-op when no redactor is registered.
/// Hands the log event's properties to the registered <see cref="ILogRedactor"/> and reconciles
/// the result back onto the event: values the redactor changed are rewritten via
/// <c>AddOrUpdateProperty</c>, and keys the redactor removed are deleted via
/// <c>RemovePropertyIfPresent</c>. No-op when no redactor is registered or the event carries no
/// properties.
/// <para>
/// The redactor sees the unwrapped value of each <see cref="ScalarValue"/> property; structured
/// values (<see cref="StructureValue"/> from <c>{@Object}</c>, <see cref="SequenceValue"/>,
/// <see cref="DictionaryValue"/>) 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 <see cref="ILogRedactor"/> for the seam's documented reach.
/// </para>
/// </summary>
/// <param name="logEvent">The log event to redact.</param>
/// <param name="propertyFactory">Factory used to materialize replacement properties.</param>
@@ -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<string, object?>(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<string>(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;
@@ -115,38 +115,13 @@ internal static class ZbSerilogConfig
}
/// <summary>
/// Builds the OTLP Resource-attribute map mirroring <c>ZbResource</c>. Null/empty optional
/// attributes are omitted, matching the shared Resource's omission rules. The
/// <c>service.instance.id</c> is sourced from <see cref="ZbResource.InstanceId"/> — the
/// same deterministic <c>MachineName:ProcessId</c> 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 <em>not</em> a parallel
/// implementation: it is derived directly from <see cref="ZbResource.BuildAttributes"/> — 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.
/// </summary>
internal static IDictionary<string, object> BuildResourceAttributes(ZbTelemetryOptions options)
{
var attributes = new Dictionary<string, object>
{
["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<string, object> BuildResourceAttributes(ZbTelemetryOptions options) =>
new Dictionary<string, object>(ZbResource.BuildAttributes(options), StringComparer.Ordinal);
}
@@ -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.
@@ -20,6 +20,17 @@
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<!-- The Serilog companion package reuses the internal options validator (single fail-fast
path); its tests assert it too. -->
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>ZB.MOM.WW.Telemetry.Serilog</_Parameter1>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>ZB.MOM.WW.Telemetry.Serilog.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup>
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" />
@@ -9,8 +9,10 @@ namespace ZB.MOM.WW.Telemetry;
public static class ZbMetricsEndpointExtensions
{
/// <summary>
/// Mounts the Prometheus <c>/metrics</c> endpoint. Only valid when
/// <see cref="ZbTelemetryOptions.Exporter"/> = <see cref="ZbExporter.Prometheus"/>.
/// Mounts the Prometheus <c>/metrics</c> endpoint. Valid under <em>any</em>
/// <see cref="ZbTelemetryOptions.Exporter"/> value: the Prometheus exporter is always wired by
/// <c>AddZbTelemetry</c>, and OTLP (<see cref="ZbExporter.Otlp"/>) is only an additive overlay —
/// so <c>/metrics</c> serves scrape data even when <c>Exporter = ZbExporter.Otlp</c>.
/// Call after <c>app.UseRouting()</c>.
/// </summary>
/// <param name="endpoints">The endpoint route builder.</param>
@@ -31,34 +31,55 @@ public static class ZbResource
Configure(ResourceBuilder.CreateDefault(), options);
/// <summary>
/// Applies the shared ZB.MOM.WW Resource attributes to an existing <see cref="ResourceBuilder"/>.
/// Internal seam so the <c>AddZbTelemetry</c> pipeline produces a Resource identical to
/// <see cref="Build"/>.
/// 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 (<see cref="Configure"/>) and the
/// Serilog OTLP log sink — derives its attributes from this one map, so logs can never drift
/// from metrics/traces. Required attributes (<c>service.name</c>, <c>service.namespace</c>,
/// <c>service.instance.id</c>, <c>host.name</c>) are always present; optional ones
/// (<c>service.version</c>, <c>site.id</c>, <c>node.role</c>) are included only when the
/// corresponding option is non-null/non-empty, matching the Resource's omission rules.
/// </summary>
internal static ResourceBuilder Configure(ResourceBuilder builder, ZbTelemetryOptions options)
/// <param name="options">The telemetry options describing the service identity.</param>
/// <returns>The canonical attribute map carried by all three signals.</returns>
public static IReadOnlyDictionary<string, object> 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<KeyValuePair<string, object>>
var attributes = new Dictionary<string, object>(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;
}
/// <summary>
/// Applies the shared ZB.MOM.WW Resource attributes to an existing <see cref="ResourceBuilder"/>.
/// Internal seam so the <c>AddZbTelemetry</c> pipeline produces a Resource identical to
/// <see cref="Build"/>. Derives every attribute from <see cref="BuildAttributes"/> — the same
/// canonical map the Serilog OTLP log sink uses — so all three signals agree.
/// </summary>
internal static ResourceBuilder Configure(ResourceBuilder builder, ZbTelemetryOptions options)
{
var attributes = BuildAttributes(options);
builder.AddAttributes(attributes);
return builder;
}
@@ -101,6 +101,7 @@ public static class ZbTelemetryExtensions
"ZbTelemetryOptions.ServiceName is required (e.g. \"otopcua\").",
nameof(configure));
}
ZbTelemetryOptionsValidator.Validate(options, nameof(configure));
return options;
}
@@ -0,0 +1,44 @@
namespace ZB.MOM.WW.Telemetry;
/// <summary>
/// Eager, fail-fast validation of <see cref="ZbTelemetryOptions"/> shared by the core
/// <c>AddZbTelemetry</c> path and the Serilog <c>AddZbSerilog</c> path, so a malformed value is
/// reported once, clearly, and with the offending option named — rather than surfacing late as a
/// bare <see cref="UriFormatException"/> deep inside exporter construction at host-build time.
/// </summary>
internal static class ZbTelemetryOptionsValidator
{
/// <summary>
/// Validates the OTLP configuration. When <see cref="ZbTelemetryOptions.Exporter"/> is
/// <see cref="ZbExporter.Otlp"/>, <see cref="ZbTelemetryOptions.OtlpEndpoint"/> must be a
/// non-empty, well-formed absolute URI. Throws an <see cref="ArgumentException"/> (naming the
/// option) otherwise. No-op for the Prometheus exporter — a stray endpoint is ignored there.
/// </summary>
/// <param name="options">The populated telemetry options to validate.</param>
/// <param name="paramName">The originating parameter name for the thrown exception.</param>
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);
}
}
}
@@ -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<string, object?> 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<string, object?> 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"));
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public void Removing_redactor_scrubs_the_property_from_the_event()
{
var serviceProvider = new ServiceCollection()
.AddSingleton<ILogRedactor>(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"));
}
/// <summary>
/// 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
/// <see cref="StructureValue"/> 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).
/// </summary>
[Fact]
public void Redactor_cannot_reach_a_field_inside_a_destructured_object()
{
var serviceProvider = new ServiceCollection()
.AddSingleton<ILogRedactor>(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"]);
}
/// <summary>
/// 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 <see cref="ZbResource.BuildAttributes"/> source of truth. This pins that they
/// cannot silently drift apart.
/// </summary>
[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()
{
@@ -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<ArgumentException>(() =>
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<ArgumentException>(() =>
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"];
});
+11 -1
View File
@@ -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
`<details>`/`<summary>` 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 `<link>` 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
```
@@ -1,3 +1,19 @@
namespace ZB.MOM.WW.Theme;
public enum ButtonVariant { Primary, Secondary, Danger, Ghost }
/// <summary>
/// Visual variant for <c>TechButton</c>, mapped to a Bootstrap <c>.btn-*</c> class.
/// </summary>
public enum ButtonVariant
{
/// <summary>Primary action — emits <c>btn-primary</c> (filled accent button).</summary>
Primary,
/// <summary>Secondary action — emits <c>btn-outline-secondary</c> (outlined button).</summary>
Secondary,
/// <summary>Destructive action — emits <c>btn-danger</c> (filled red button).</summary>
Danger,
/// <summary>Low-emphasis action — emits <c>btn-link</c> (text-only button).</summary>
Ghost,
}
@@ -7,6 +7,11 @@
</div>
@code {
/// <summary>Product name displayed next to the brand glyph. Required.</summary>
[Parameter, EditorRequired] public string Product { get; set; } = string.Empty;
/// <summary>
/// Optional custom logo. When provided it replaces the default brand glyph (<c>▐</c>).
/// </summary>
[Parameter] public RenderFragment? Logo { get; set; }
}
@@ -36,7 +36,12 @@
</div>
@code {
/// <summary>Product name shown in the card heading. Required.</summary>
[Parameter, EditorRequired] public string Product { get; set; } = string.Empty;
/// <summary>
/// Form <c>action</c> URL the sign-in POST targets. Defaults to <c>/auth/login</c>.
/// </summary>
[Parameter] public string Action { get; set; } = "/auth/login";
/// <summary>
@@ -47,6 +52,9 @@
/// </summary>
[Parameter] public string? ReturnUrl { get; set; }
/// <summary>
/// Optional error message shown as a notice above the submit button (e.g. a failed login).
/// </summary>
[Parameter] public string? Error { get; set; }
/// <summary>
@@ -6,8 +6,21 @@
</NavLink>
@code {
/// <summary>Link target (the <c>href</c> of the underlying <c>NavLink</c>). Required.</summary>
[Parameter, EditorRequired] public string Href { get; set; } = string.Empty;
/// <summary>Visible label text for the rail link. Required.</summary>
[Parameter, EditorRequired] public string Text { get; set; } = string.Empty;
/// <summary>
/// Optional leading icon, rendered in a <c>.rail-ico</c> span before the label.
/// </summary>
[Parameter] public RenderFragment? Icon { get; set; }
/// <summary>
/// Active-class matching behaviour passed to the underlying <c>NavLink</c>.
/// Defaults to <see cref="NavLinkMatch.Prefix"/>; use <see cref="NavLinkMatch.All"/>
/// for exact matches such as the home/overview link.
/// </summary>
[Parameter] public NavLinkMatch Match { get; set; } = NavLinkMatch.Prefix;
}
@@ -2,12 +2,24 @@
Apps that want cookie-persisted expand state keep their own interactive NavSection. *@
@namespace ZB.MOM.WW.Theme
<details class="rail-section" open="@Expanded">
<summary class="rail-eyebrow-toggle"><span class="rail-eyebrow-label">@Title</span></summary>
<summary class="rail-eyebrow-toggle">@Title</summary>
<div class="rail-section-body">@ChildContent</div>
</details>
@code {
/// <summary>
/// Eyebrow label shown in the section's <c>&lt;summary&gt;</c> header. Required.
/// </summary>
[Parameter, EditorRequired] public string Title { get; set; } = string.Empty;
/// <summary>
/// Initial open state of the collapsible section. Defaults to <c>true</c> (expanded).
/// Backed by the native <c>&lt;details open&gt;</c> attribute — no JavaScript required.
/// </summary>
[Parameter] public bool Expanded { get; set; } = true;
/// <summary>
/// Section content — typically <see cref="NavRailItem"/> children.
/// </summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
}
@@ -3,7 +3,13 @@
<span class="chip @ChipClass">@ChildContent</span>
@code {
/// <summary>
/// Status the chip represents. Mapped to a token-based <c>.chip-*</c> class
/// (e.g. <see cref="StatusState.Ok"/> → <c>chip-ok</c>). Required.
/// </summary>
[Parameter, EditorRequired] public StatusState State { get; set; }
/// <summary>Chip label content (e.g. the status text).</summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
private string ChipClass => State switch
@@ -6,10 +6,28 @@
</button>
@code {
/// <summary>
/// Visual variant, mapped to a Bootstrap <c>.btn-*</c> class.
/// Defaults to <see cref="ButtonVariant.Primary"/>.
/// </summary>
[Parameter] public ButtonVariant Variant { get; set; } = ButtonVariant.Primary;
/// <summary>HTML <c>type</c> attribute (<c>button</c>, <c>submit</c>, <c>reset</c>). Defaults to <c>button</c>.</summary>
[Parameter] public string Type { get; set; } = "button";
/// <summary>
/// When <c>true</c>, disables the button and shows an inline spinner — use while an
/// async action is in flight. Defaults to <c>false</c>.
/// </summary>
[Parameter] public bool Busy { get; set; }
/// <summary>Button label content.</summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
/// <summary>
/// Arbitrary additional HTML attributes splatted onto the underlying
/// <c>&lt;button&gt;</c> (e.g. <c>id</c>, <c>@onclick</c>, <c>name</c>, <c>value</c>).
/// </summary>
[Parameter(CaptureUnmatchedValues = true)] public IDictionary<string, object>? AdditionalAttributes { get; set; }
private string VariantClass => Variant switch
@@ -8,9 +8,23 @@
</section>
@code {
/// <summary>
/// Optional string title rendered in the panel header. Ignored when <see cref="Header"/>
/// is supplied (<see cref="Header"/> takes precedence).
/// </summary>
[Parameter] public string? Title { get; set; }
/// <summary>
/// Optional custom panel header content. Takes precedence over <see cref="Title"/>.
/// </summary>
[Parameter] public RenderFragment? Header { get; set; }
/// <summary>Panel body content.</summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
/// <summary>Optional panel footer content (padded, top-bordered).</summary>
[Parameter] public RenderFragment? Footer { get; set; }
/// <summary>Additional CSS classes appended to the root <c>&lt;section class="panel"&gt;</c>.</summary>
[Parameter] public string? Class { get; set; }
}
@@ -8,8 +8,17 @@
</div>
@code {
/// <summary>Field label text. Required.</summary>
[Parameter, EditorRequired] public string Label { get; set; } = string.Empty;
/// <summary>Optional hint text rendered as <c>.form-text</c> below the input.</summary>
[Parameter] public string? Hint { get; set; }
/// <summary>
/// Optional inline error message rendered as <c>.field-error.s-bad</c> below the input.
/// </summary>
[Parameter] public string? Error { get; set; }
/// <summary>The control itself — an <c>&lt;input&gt;</c>, <c>&lt;select&gt;</c>, etc.</summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
}
@@ -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 <details> disclosure (no
Bootstrap JS): the rail is force-shown on lg+ and toggles via the native
<details> open state below lg, so it works in pure static SSR. *@
@namespace ZB.MOM.WW.Theme
<div class="app-shell d-flex flex-column flex-lg-row" style="@AccentStyle">
<button class="btn btn-outline-secondary btn-sm d-lg-none m-2 align-self-start"
type="button" data-bs-toggle="collapse" data-bs-target="#theme-rail"
aria-controls="theme-rail" aria-expanded="false" aria-label="Toggle navigation">
<details class="app-shell d-flex flex-column flex-lg-row" style="@AccentStyle">
<summary class="rail-toggle btn btn-outline-secondary btn-sm d-lg-none m-2 align-self-start"
aria-label="Toggle navigation">
&#9776;
</button>
<div class="collapse d-lg-block" id="theme-rail">
</summary>
<div class="theme-rail" id="theme-rail">
<nav class="side-rail">
<BrandBar Product="@Product" Logo="@Logo" />
@Nav
@@ -18,14 +20,41 @@
</nav>
</div>
<main class="page">@ChildContent</main>
</div>
</details>
@code {
/// <summary>
/// Product name shown in the rail's <see cref="BrandBar"/> header. Required.
/// </summary>
[Parameter, EditorRequired] public string Product { get; set; } = string.Empty;
/// <summary>
/// Optional per-app accent colour. When set, emitted as <c>style="--accent: &lt;value&gt;"</c>
/// on the shell root, overriding the <c>--accent</c> design token for this subtree only.
/// This is the single per-app token override the kit allows (e.g. <c>"#2f855a"</c>).
/// </summary>
[Parameter] public string? Accent { get; set; }
/// <summary>
/// Optional custom logo rendered in the rail header. When omitted, the default
/// brand glyph is shown.
/// </summary>
[Parameter] public RenderFragment? Logo { get; set; }
/// <summary>
/// Rail navigation content — typically <see cref="NavRailSection"/> /
/// <see cref="NavRailItem"/> elements.
/// </summary>
[Parameter] public RenderFragment? Nav { get; set; }
/// <summary>
/// Optional content pinned to the bottom of the rail (e.g. session info / sign-out).
/// </summary>
[Parameter] public RenderFragment? RailFooter { get; set; }
/// <summary>
/// The page body — normally <c>@Body</c> forwarded from the app's <c>MainLayout</c>.
/// </summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
private string? AccentStyle => Accent is null ? null : $"--accent: {Accent}";
@@ -1,3 +1,23 @@
namespace ZB.MOM.WW.Theme;
public enum StatusState { Ok, Warn, Bad, Idle, Info }
/// <summary>
/// Status conveyed by a <c>StatusPill</c> chip, mapped to a token-based
/// <c>.chip-*</c> class (status is communicated by colour, not iconography).
/// </summary>
public enum StatusState
{
/// <summary>Success / healthy / connected — emits <c>chip-ok</c> (green).</summary>
Ok,
/// <summary>Warning / degraded — emits <c>chip-warn</c> (amber).</summary>
Warn,
/// <summary>Error / faulted / disconnected — emits <c>chip-bad</c> (red).</summary>
Bad,
/// <summary>Unknown / offline / neutral — emits <c>chip-idle</c> (grey). The default.</summary>
Idle,
/// <summary>Informational — emits <c>chip-info</c> (on-palette blue).</summary>
Info,
}
@@ -2,6 +2,13 @@
<PropertyGroup>
<RootNamespace>ZB.MOM.WW.Theme</RootNamespace>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- Emit the XML doc file so missing-doc (CS1591) warnings on the public API
surface under TreatWarningsAsErrors. Razor compiles each component to a
public class whose generated members carry no docs, so CS1591 is left
out of the error set (it would flag generated members, not authored ones)
while still producing the doc XML consumers see at the call site. -->
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<IsPackable>true</IsPackable>
<PackageId>ZB.MOM.WW.Theme</PackageId>
<Description>Shared Technical-Light UI kit (tokens, fonts, side-rail shell, widgets) for the ZB.MOM.WW SCADA family.</Description>
@@ -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 <lg viewports and the rail sits beside the page on lg+. */
/* The shell is a native <details> disclosure so the narrow-viewport hamburger
toggle works with NO JavaScript (no Bootstrap collapse bundle): below lg the
<summary> 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 <lg viewports and the rail sits beside the page on lg+. */
.app-shell {
align-items: stretch;
min-height: calc(100vh - 3.3rem);
@@ -15,6 +19,18 @@
min-width: 0;
}
/* Hamburger <summary>: 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 <details> 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 <details> 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 <lg viewports the Bootstrap collapse
container removes the fixed width; restore full width on mobile. */
/* When the side rail is collapsed under <lg viewports (the <details> 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 <summary> 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; }
@@ -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);
}
@@ -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<NavRailItem>(p => p
.Add(x => x.Href, "/clusters")
.Add(x => x.Text, "Clusters")
.Add(x => x.Icon, (RenderFragment)(b => b.AddMarkupContent(0, "<svg class='ico'/>"))));
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<NavRailItem>(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()
{
@@ -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)}");
}
}
@@ -40,4 +40,26 @@ public class ThemeShellTests : TestContext
.Add(x => x.RailFooter, (RenderFragment)(b => b.AddMarkupContent(0, "<span class='sess'>S</span>"))));
Assert.NotNull(cut.Find(".rail-foot .sess"));
}
// Theme-001: the mobile hamburger must work without Bootstrap collapse JS.
// The shell is a native <details> disclosure whose <summary> 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<ThemeShell>(p => p.Add(x => x.Product, "OtOpcUa"));
// Root is a <details> whose direct <summary> 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 <details> — 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);
}
}
@@ -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<string, object> 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);
@@ -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 |
+1
View File
@@ -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` |