From 2744011ce9ee8746e811fc7d08759a8df9379023 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 19:13:19 -0400 Subject: [PATCH] feat(health): surface AuditRedactionFailure in central snapshot (#23 M6) --- .../CentralAuditRedactionFailureCounter.cs | 57 +++++++++++ .../ServiceCollectionExtensions.cs | 16 +-- ...entralAuditRedactionFailureCounterTests.cs | 99 +++++++++++++++++++ 3 files changed, 166 insertions(+), 6 deletions(-) create mode 100644 src/ScadaLink.AuditLog/Central/CentralAuditRedactionFailureCounter.cs create mode 100644 tests/ScadaLink.AuditLog.Tests/Central/CentralAuditRedactionFailureCounterTests.cs diff --git a/src/ScadaLink.AuditLog/Central/CentralAuditRedactionFailureCounter.cs b/src/ScadaLink.AuditLog/Central/CentralAuditRedactionFailureCounter.cs new file mode 100644 index 0000000..102b6d9 --- /dev/null +++ b/src/ScadaLink.AuditLog/Central/CentralAuditRedactionFailureCounter.cs @@ -0,0 +1,57 @@ +using ScadaLink.AuditLog.Payload; + +namespace ScadaLink.AuditLog.Central; + +/// +/// Audit Log (#23) M6 Bundle E (T9) — bridges +/// (incremented by +/// every time a header / body / SQL +/// parameter redactor stage throws and the filter has to over-redact the +/// offending field) into so the +/// failure surfaces on the central health surface as +/// AuditCentralHealthSnapshot.AuditRedactionFailure. +/// +/// +/// +/// Site vs central. M5 Bundle C wired the SITE-side bridge +/// (), +/// which routes increments into the site health report payload's +/// AuditRedactionFailure field. That handles redactor failures on the +/// site SQLite hot-path (FallbackAuditWriter). M6 Bundle E (T9) adds the +/// MIRROR bridge here so the same payload filter — when it runs on the +/// central / +/// paths — surfaces its failures on the +/// central dashboard rather than disappearing into a NoOp. +/// +/// +/// Registration shape. Site composition roots call +/// , +/// which overrides the binding with the site bridge. Central composition +/// roots call , +/// which overrides with this central bridge. A node never wears both hats — +/// site and central are distinct host roles — so the two bridges never +/// fight over the same binding at runtime. +/// +/// +/// Why not a thin wrapper around the snapshot directly? The snapshot +/// itself could be the bound implementation (it already implements +/// ), but a dedicated class makes +/// the central-vs-site asymmetry explicit at the DI boundary — readers of +/// +/// see "site → site bridge, central → central bridge", matching the +/// +/// shape one-for-one. +/// +/// +public sealed class CentralAuditRedactionFailureCounter : IAuditRedactionFailureCounter +{ + private readonly AuditCentralHealthSnapshot _snapshot; + + public CentralAuditRedactionFailureCounter(AuditCentralHealthSnapshot snapshot) + { + _snapshot = snapshot ?? throw new ArgumentNullException(nameof(snapshot)); + } + + /// + public void Increment() => ((IAuditRedactionFailureCounter)_snapshot).Increment(); +} diff --git a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs index 2fe18ea..6460b1a 100644 --- a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs @@ -295,14 +295,18 @@ public static class ServiceCollectionExtensions services.Replace(ServiceDescriptor.Singleton( sp => sp.GetRequiredService())); // M6 Bundle E (T9): override the NoOp IAuditRedactionFailureCounter - // (registered by AddAuditLog) with the central snapshot binding so - // payload-filter throws on CentralAuditWriter / AuditLogIngestActor - // paths surface on the central dashboard. The site composition root + // (registered by AddAuditLog) with the CentralAuditRedactionFailureCounter + // bridge so payload-filter throws on CentralAuditWriter / + // AuditLogIngestActor paths surface on the central dashboard. The + // bridge is a thin wrapper around the AuditCentralHealthSnapshot + // singleton so all central redactor failures route into the same + // counter as CentralAuditWriteFailures. The site composition root // overrides this binding AGAIN via AddAuditLogHealthMetricsBridge — // central nodes do not call that bridge, so this is the final - // binding on a central host. - services.Replace(ServiceDescriptor.Singleton( - sp => sp.GetRequiredService())); + // binding on a central host. Mirrors the M5 Bundle C + // HealthMetricsAuditRedactionFailureCounter shape one-for-one. + services.Replace(ServiceDescriptor.Singleton()); return services; } diff --git a/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditRedactionFailureCounterTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditRedactionFailureCounterTests.cs new file mode 100644 index 0000000..7fbfebf --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditRedactionFailureCounterTests.cs @@ -0,0 +1,99 @@ +using Akka.Actor; +using Akka.TestKit.Xunit2; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using ScadaLink.AuditLog; +using ScadaLink.AuditLog.Central; +using ScadaLink.AuditLog.Payload; + +namespace ScadaLink.AuditLog.Tests.Central; + +/// +/// Bundle E (M6-T9) coverage for the central-side payload-filter redactor +/// failure bridge. M5 wired the SITE bridge +/// (HealthMetricsAuditRedactionFailureCounter) that pushes increments +/// into the site health report; M6 mirrors that with +/// so the same payload +/// filter — when it runs on the central writer paths — surfaces failures on +/// the central . +/// +public class CentralAuditRedactionFailureCounterTests : TestKit +{ + [Fact] + public void Increment_Routes_To_Snapshot() + { + using var tracker = new SiteAuditTelemetryStalledTracker(Sys); + var snapshot = new AuditCentralHealthSnapshot(tracker); + var counter = new CentralAuditRedactionFailureCounter(snapshot); + + counter.Increment(); + counter.Increment(); + counter.Increment(); + + Assert.Equal(3, snapshot.AuditRedactionFailure); + } + + [Fact] + public void Construction_With_Null_Snapshot_Throws() + { + Assert.Throws( + () => new CentralAuditRedactionFailureCounter(null!)); + } + + [Fact] + public void AddAuditLogCentralMaintenance_Replaces_IAuditRedactionFailureCounter_With_CentralImpl() + { + // AddAuditLog registers NoOp; AddAuditLogCentralMaintenance is the + // override path. The replaced binding MUST resolve to the central + // bridge — a site host that wires AddAuditLogHealthMetricsBridge + // instead would resolve to the site bridge (covered in + // AddAuditLogTests). + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["AuditLog:SiteWriter:DatabasePath"] = ":memory:", + }) + .Build(); + + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + // The AuditCentralHealthSnapshot ctor takes the stalled tracker + // which itself needs an ActorSystem — register a real system + // (test-kit's Sys) so the DI graph composes. + services.AddSingleton(Sys); + services.AddAuditLog(config); + services.AddAuditLogCentralMaintenance(config); + using var provider = services.BuildServiceProvider(); + + var counter = provider.GetRequiredService(); + + Assert.IsType(counter); + } + + [Fact] + public void AddAuditLog_Default_IAuditRedactionFailureCounter_Is_NoOp() + { + // Sanity check: without AddAuditLogCentralMaintenance the default + // remains the NoOp from M5 — the central bridge only takes effect + // when the central-only registration runs. + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["AuditLog:SiteWriter:DatabasePath"] = ":memory:", + }) + .Build(); + + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + services.AddAuditLog(config); + using var provider = services.BuildServiceProvider(); + + var counter = provider.GetRequiredService(); + + Assert.IsType(counter); + } +}