From 9b1379ed9b6e0dc68dd536be0450c18b12624ac8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 17:21:57 -0400 Subject: [PATCH] feat(auditlog): wire IAuditPayloadFilter into all writer paths (#23 M5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundle C task M5-T6 — plugs the IAuditPayloadFilter singleton into the three audit writer entry points so every event is truncated + redacted before persistence, regardless of which path it took to disk: - FallbackAuditWriter (site hot path): filter runs before the primary SQLite write AND the ring-buffer enqueue, so a recovery drain replays rows that are already capped/redacted. - CentralAuditWriter (central direct-write): filter runs before the per-call IAuditLogRepository.InsertIfNotExistsAsync. - AuditLogIngestActor (site→central telemetry): - OnIngestAsync resolves the filter from the per-message scope and applies it to each row before IngestedAtUtc stamping. - OnCachedTelemetryAsync (M3 dual-write) applies the filter to the audit half of every CachedTelemetryEntry before the audit-insert + site-call-upsert transaction. Filter parameter is optional (nullable) on each constructor so the existing test composition roots that don't pass one keep working unchanged — production DI wiring in AddAuditLog always passes the real filter through. ICentralAuditWriter registration switched from the open-ctor form to a factory so the filter flows through it. Tests: FilterIntegrationTests covers all three writer paths end-to-end (4 tests). Full ScadaLink.AuditLog.Tests suite: 146 passed, 0 failed, 0 skipped. --- .../Central/AuditLogIngestActor.cs | 28 +- .../Central/CentralAuditWriter.cs | 23 +- .../ServiceCollectionExtensions.cs | 15 +- .../Site/FallbackAuditWriter.cs | 34 +- .../Payload/FilterIntegrationTests.cs | 301 ++++++++++++++++++ 5 files changed, 391 insertions(+), 10 deletions(-) create mode 100644 tests/ScadaLink.AuditLog.Tests/Payload/FilterIntegrationTests.cs diff --git a/src/ScadaLink.AuditLog/Central/AuditLogIngestActor.cs b/src/ScadaLink.AuditLog/Central/AuditLogIngestActor.cs index 2b2c580..8e7f21b 100644 --- a/src/ScadaLink.AuditLog/Central/AuditLogIngestActor.cs +++ b/src/ScadaLink.AuditLog/Central/AuditLogIngestActor.cs @@ -1,6 +1,7 @@ using Akka.Actor; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using ScadaLink.AuditLog.Payload; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Messages.Audit; @@ -114,8 +115,15 @@ public class AuditLogIngestActor : ReceiveActor // Resolve the repository for the whole batch — one DbContext per // message, mirroring NotificationOutboxActor. The injected-repository // mode (Bundle D tests) skips the scope entirely. + // Bundle C (M5-T6): the IAuditPayloadFilter is also resolved from the + // per-message scope when one is available so the row is truncated + + // redacted before InsertIfNotExistsAsync. The single-repository test + // ctor has no service provider — it falls through with no filter, + // which preserves the small-payload assumptions baked into the + // existing D2 fixtures. IServiceScope? scope = null; IAuditLogRepository repository; + IAuditPayloadFilter? filter = null; if (_injectedRepository is not null) { repository = _injectedRepository; @@ -124,6 +132,7 @@ public class AuditLogIngestActor : ReceiveActor { scope = _serviceProvider!.CreateScope(); repository = scope.ServiceProvider.GetRequiredService(); + filter = scope.ServiceProvider.GetService(); } try @@ -136,7 +145,11 @@ public class AuditLogIngestActor : ReceiveActor // repository hardening already swallows duplicate-key races, // so the same id arriving twice (site retry, reconciliation) // is a silent no-op. - var ingested = evt with { IngestedAtUtc = nowUtc }; + // Filter BEFORE the IngestedAtUtc stamp so the redacted + // copy carries the central-side ingest timestamp. Filter + // is contract-bound to never throw; null = pass-through. + var filtered = filter?.Apply(evt) ?? evt; + var ingested = filtered with { IngestedAtUtc = nowUtc }; await repository.InsertIfNotExistsAsync(ingested).ConfigureAwait(false); accepted.Add(evt.EventId); } @@ -185,6 +198,12 @@ public class AuditLogIngestActor : ReceiveActor var auditRepo = scope.ServiceProvider.GetRequiredService(); var siteCallRepo = scope.ServiceProvider.GetRequiredService(); var dbContext = scope.ServiceProvider.GetRequiredService(); + // Bundle C (M5-T6): resolve the filter for the whole batch from + // the scope; null = pass-through for test composition roots that + // skip the filter registration. The filter is contract-bound to + // never throw, so we can apply it inside the per-entry try + // without risking an unbounded blast radius. + var filter = scope.ServiceProvider.GetService(); foreach (var entry in cmd.Entries) { @@ -199,7 +218,12 @@ public class AuditLogIngestActor : ReceiveActor // matching timestamps (debugging convenience, not a // correctness invariant). var ingestedAt = DateTime.UtcNow; - var auditStamped = entry.Audit with { IngestedAtUtc = ingestedAt }; + // Filter the audit half BEFORE the dual-write — only the + // AuditLog row's payload columns are filterable; SiteCalls + // carries operational state only (status, retry count) and + // is left untouched. + var filteredAudit = filter?.Apply(entry.Audit) ?? entry.Audit; + var auditStamped = filteredAudit with { IngestedAtUtc = ingestedAt }; var siteCallStamped = entry.SiteCall with { IngestedAtUtc = ingestedAt }; await auditRepo.InsertIfNotExistsAsync(auditStamped) diff --git a/src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs b/src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs index fd0972d..ff48bea 100644 --- a/src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs +++ b/src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using ScadaLink.AuditLog.Payload; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Services; @@ -40,11 +41,24 @@ public sealed class CentralAuditWriter : ICentralAuditWriter { private readonly IServiceProvider _services; private readonly ILogger _logger; + private readonly IAuditPayloadFilter? _filter; - public CentralAuditWriter(IServiceProvider services, ILogger logger) + /// + /// Bundle C (M5-T6) — the central direct-write path used by the + /// NotificationOutboxActor dispatch and the Inbound API middleware also + /// needs to truncate + redact before the row hits MS SQL. The filter is + /// optional so the M4 test composition roots that don't pass one keep + /// working (they only ever write small payloads); production DI registers + /// the real filter via . + /// + public CentralAuditWriter( + IServiceProvider services, + ILogger logger, + IAuditPayloadFilter? filter = null) { _services = services ?? throw new ArgumentNullException(nameof(services)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _filter = filter; } /// @@ -65,9 +79,14 @@ public sealed class CentralAuditWriter : ICentralAuditWriter try { + // Filter BEFORE stamping IngestedAtUtc + handing to the repo. The + // filter contract is "never throws"; the null-coalesce keeps the + // M4 test composition roots (no filter passed) working unchanged. + var filtered = _filter?.Apply(evt) ?? evt; + await using var scope = _services.CreateAsyncScope(); var repo = scope.ServiceProvider.GetRequiredService(); - var stamped = evt with { IngestedAtUtc = DateTime.UtcNow }; + var stamped = filtered with { IngestedAtUtc = DateTime.UtcNow }; await repo.InsertIfNotExistsAsync(stamped, ct).ConfigureAwait(false); } catch (Exception ex) diff --git a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs index 2200e72..99ac5d9 100644 --- a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs @@ -106,11 +106,16 @@ public static class ServiceCollectionExtensions // The script-thread surface is FallbackAuditWriter (primary + ring + // counter), not the raw SqliteAuditWriter — primary failures must NEVER // abort the user-facing action. + // Bundle C (M5-T6): the IAuditPayloadFilter singleton above is wired + // through the factory so every event written through this surface is + // truncated + redacted before it hits SQLite (and the ring on + // failure). services.AddSingleton(sp => new FallbackAuditWriter( primary: sp.GetRequiredService(), ring: sp.GetRequiredService(), failureCounter: sp.GetRequiredService(), - logger: sp.GetRequiredService>())); + logger: sp.GetRequiredService>(), + filter: sp.GetRequiredService())); // ISiteStreamAuditClient: NoOp default. M6's reconciliation work brings // the real gRPC-backed implementation (no site→central gRPC channel @@ -155,7 +160,13 @@ public static class ServiceCollectionExtensions // is intentionally distinct from IAuditWriter so site composition roots // do not accidentally bind it; central composition roots that include // AddConfigurationDatabase get a working implementation transparently. - services.AddSingleton(); + // Bundle C (M5-T6): wire the IAuditPayloadFilter into the factory so + // NotificationOutboxActor + Inbound API rows are truncated + redacted + // before they hit MS SQL. + services.AddSingleton(sp => new CentralAuditWriter( + sp, + sp.GetRequiredService>(), + sp.GetRequiredService())); return services; } diff --git a/src/ScadaLink.AuditLog/Site/FallbackAuditWriter.cs b/src/ScadaLink.AuditLog/Site/FallbackAuditWriter.cs index 9b911c5..18511f1 100644 --- a/src/ScadaLink.AuditLog/Site/FallbackAuditWriter.cs +++ b/src/ScadaLink.AuditLog/Site/FallbackAuditWriter.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging; +using ScadaLink.AuditLog.Payload; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces.Services; @@ -30,27 +31,48 @@ public sealed class FallbackAuditWriter : IAuditWriter private readonly RingBufferFallback _ring; private readonly IAuditWriteFailureCounter _failureCounter; private readonly ILogger _logger; + private readonly IAuditPayloadFilter? _filter; private readonly SemaphoreSlim _drainGate = new(1, 1); + /// + /// Bundle C (M5-T6) wires the singleton + /// here so every event written via the site hot path is truncated + + /// header/body/SQL-param redacted before it hits both the primary SQLite + /// writer AND the ring fallback. The parameter is optional (defaults to + /// no filtering) so the long tail of test composition roots that don't + /// care about the filter need no change — the production + /// registration + /// always passes the real filter through. + /// public FallbackAuditWriter( IAuditWriter primary, RingBufferFallback ring, IAuditWriteFailureCounter failureCounter, - ILogger logger) + ILogger logger, + IAuditPayloadFilter? filter = null) { _primary = primary ?? throw new ArgumentNullException(nameof(primary)); _ring = ring ?? throw new ArgumentNullException(nameof(ring)); _failureCounter = failureCounter ?? throw new ArgumentNullException(nameof(failureCounter)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _filter = filter; // null = no-op pass-through; see WriteAsync. } public async Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(evt); + // Filter once, up-front. The filtered event flows BOTH to the primary + // and (on failure) to the ring buffer — so a primary outage that + // drains later still hands the SqliteAuditWriter a row that has + // already been truncated and redacted. The filter contract is + // "MUST NOT throw"; the null-coalesce keeps test composition roots + // that don't wire a filter working unchanged. + var filtered = _filter?.Apply(evt) ?? evt; + try { - await _primary.WriteAsync(evt, ct).ConfigureAwait(false); + await _primary.WriteAsync(filtered, ct).ConfigureAwait(false); } catch (Exception ex) { @@ -62,8 +84,12 @@ public sealed class FallbackAuditWriter : IAuditWriter _failureCounter.Increment(); _logger.LogWarning(ex, "Primary audit writer threw; routing EventId {EventId} to drop-oldest ring.", - evt.EventId); - _ring.TryEnqueue(evt); + filtered.EventId); + // Ring stores the filtered copy so the eventual drain replays a + // payload that has already been capped/redacted — no second + // filter pass needed on recovery, and no risk of the ring + // holding the raw oversized blob in memory. + _ring.TryEnqueue(filtered); return; } diff --git a/tests/ScadaLink.AuditLog.Tests/Payload/FilterIntegrationTests.cs b/tests/ScadaLink.AuditLog.Tests/Payload/FilterIntegrationTests.cs new file mode 100644 index 0000000..ca3aaab --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/Payload/FilterIntegrationTests.cs @@ -0,0 +1,301 @@ +using System.Text; +using Akka.Actor; +using Akka.TestKit.Xunit2; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using ScadaLink.AuditLog.Central; +using ScadaLink.AuditLog.Configuration; +using ScadaLink.AuditLog.Payload; +using ScadaLink.AuditLog.Site; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Interfaces.Services; +using ScadaLink.Commons.Messages.Audit; +using ScadaLink.Commons.Types; +using ScadaLink.Commons.Types.Enums; +using ScadaLink.ConfigurationDatabase; +using ScadaLink.ConfigurationDatabase.Repositories; +using ScadaLink.ConfigurationDatabase.Tests.Migrations; + +namespace ScadaLink.AuditLog.Tests.Payload; + +/// +/// Bundle C (M5-T6) integration tests verifying that the +/// wires correctly into each of the three +/// writer entry points — on the site hot +/// path, on the central direct-write path, +/// and on the site→central telemetry ingest +/// path (both the per-row IngestAuditEventsCommand handler and the +/// combined IngestCachedTelemetryCommand dual-write handler). +/// +/// +/// Bundle B established the filter's behaviour in isolation (truncation, +/// header redaction, body-regex redaction, SQL-parameter redaction). Bundle C +/// proves that filtering actually happens before persistence — a 10 KB +/// RequestSummary on a Delivered row must land on disk capped to 8192 bytes +/// with PayloadTruncated=true, regardless of whether the row was +/// written via the site's SQLite hot path, the central direct-write path, or +/// the site→central ingest pipeline. We use the production +/// through every test so the +/// integration is real end-to-end, not a fake-filter assertion. +/// +public class FilterIntegrationTests +{ + /// + /// Default-options filter — 8 KiB cap on success rows, 64 KiB on error + /// rows. Cached and reused; the filter is stateless w.r.t. the per-event + /// inputs and the regex cache is happy under sharing. + /// + private static IAuditPayloadFilter NewDefaultFilter() + { + var monitor = Microsoft.Extensions.Options.Options.Create(new AuditLogOptions()); + return new DefaultAuditPayloadFilter( + new StaticMonitor(monitor.Value), + NullLogger.Instance); + } + + private static AuditEvent NewEvent(string? request = null, Guid? eventId = null) => new() + { + EventId = eventId ?? Guid.NewGuid(), + OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc), + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCall, + // Delivered = success cap (8 KiB). Picking a success status so the + // 10 KB payload reliably trips the filter. + Status = AuditStatus.Delivered, + RequestSummary = request, + PayloadTruncated = false, + ForwardState = AuditForwardState.Pending, + }; + + // -- C1.1: FallbackAuditWriter applies the filter before SQLite write ---- + + [Fact] + public async Task FallbackAuditWriter_AppliesFilter_BeforeSqliteWrite() + { + var dataSource = + $"file:filter-fbw-{Guid.NewGuid():N}?mode=memory&cache=shared"; + // Hold the in-memory database alive for the verifier connection — + // SQLite frees a Cache=Shared in-memory DB when the last connection + // closes, so without this keep-alive the FallbackAuditWriter's + // dispose would wipe the data before we could query it. + using var keepAlive = new SqliteConnection($"Data Source={dataSource};Cache=Shared"); + keepAlive.Open(); + + var sqliteWriter = new SqliteAuditWriter( + Microsoft.Extensions.Options.Options.Create(new SqliteAuditWriterOptions { DatabasePath = dataSource }), + NullLogger.Instance, + connectionStringOverride: $"Data Source={dataSource};Cache=Shared"); + await using var _disposeSqlite = sqliteWriter; + + var fallback = new FallbackAuditWriter( + sqliteWriter, + new RingBufferFallback(), + new NoOpAuditWriteFailureCounter(), + NullLogger.Instance, + NewDefaultFilter()); + + var bigRequest = new string('a', 10 * 1024); + var evt = NewEvent(request: bigRequest); + await fallback.WriteAsync(evt); + + // Read back via a fresh connection so we observe what actually + // landed in SQLite — not what the writer was handed. + using var verifier = new SqliteConnection($"Data Source={dataSource};Cache=Shared"); + verifier.Open(); + using var cmd = verifier.CreateCommand(); + cmd.CommandText = "SELECT RequestSummary, PayloadTruncated FROM AuditLog WHERE EventId = $id;"; + cmd.Parameters.AddWithValue("$id", evt.EventId.ToString()); + using var reader = cmd.ExecuteReader(); + Assert.True(reader.Read()); + var persistedRequest = reader.GetString(0); + var truncatedFlag = reader.GetInt32(1); + + Assert.Equal(8192, Encoding.UTF8.GetByteCount(persistedRequest)); + Assert.Equal(1, truncatedFlag); + } + + // -- C1.2: CentralAuditWriter applies the filter before repo insert ------ + + [Fact] + public async Task CentralAuditWriter_AppliesFilter_BeforeRepoInsert() + { + var repo = Substitute.For(); + var services = new ServiceCollection(); + services.AddScoped(_ => repo); + services.AddSingleton(NewDefaultFilter()); + var provider = services.BuildServiceProvider(); + + var writer = new CentralAuditWriter( + provider, NullLogger.Instance, NewDefaultFilter()); + + var bigRequest = new string('b', 10 * 1024); + var evt = NewEvent(request: bigRequest); + await writer.WriteAsync(evt); + + // Verify the repository saw the FILTERED event, not the raw one. + // The filter caps RequestSummary to 8192 bytes on a Delivered row + // and flags PayloadTruncated. + await repo.Received(1).InsertIfNotExistsAsync( + Arg.Is(e => + e.EventId == evt.EventId + && e.RequestSummary != null + && Encoding.UTF8.GetByteCount(e.RequestSummary) == 8192 + && e.PayloadTruncated == true), + Arg.Any()); + } + + // -- C1.3 + C1.4: AuditLogIngestActor applies the filter on both paths --- + + public class IngestActorTests : TestKit, IClassFixture + { + private readonly MsSqlMigrationFixture _fixture; + + public IngestActorTests(MsSqlMigrationFixture fixture) + { + _fixture = fixture; + } + + private ScadaLinkDbContext CreateReadContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer(_fixture.ConnectionString) + .Options; + return new ScadaLinkDbContext(options); + } + + private static string NewSiteId() => + "test-bundle-c1-filter-" + Guid.NewGuid().ToString("N").Substring(0, 8); + + /// + /// Build the IServiceProvider in the production-flavoured shape — + /// scoped repositories + a singleton + /// resolved per-message from the actor's scope. Matches the + /// AddAuditLog registrations Bundle B established. + /// + private IServiceProvider BuildServiceProvider() + { + var services = new ServiceCollection(); + services.AddDbContext(opts => + opts.UseSqlServer(_fixture.ConnectionString) + .ConfigureWarnings(w => w.Ignore( + Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning))); + services.AddScoped(sp => + new AuditLogRepository(sp.GetRequiredService())); + services.AddScoped(sp => + new SiteCallAuditRepository(sp.GetRequiredService())); + services.AddSingleton(NewDefaultFilter()); + return services.BuildServiceProvider(); + } + + [SkippableFact] + public async Task AuditLogIngestActor_AppliesFilter_BeforeBatchInsert() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + var bigRequest = new string('c', 10 * 1024); + var evt = new AuditEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc), + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCall, + Status = AuditStatus.Delivered, + SourceSiteId = siteId, + RequestSummary = bigRequest, + PayloadTruncated = false, + }; + + var sp = BuildServiceProvider(); + var actor = Sys.ActorOf(Props.Create(() => new AuditLogIngestActor( + sp, NullLogger.Instance))); + + actor.Tell(new IngestAuditEventsCommand(new[] { evt }), TestActor); + ExpectMsg(TimeSpan.FromSeconds(15)); + + // Verify the persisted row was filtered before INSERT. + await using var read = CreateReadContext(); + var row = await read.Set() + .SingleAsync(e => e.EventId == evt.EventId); + Assert.NotNull(row.RequestSummary); + Assert.Equal(8192, Encoding.UTF8.GetByteCount(row.RequestSummary!)); + Assert.True(row.PayloadTruncated); + } + + [SkippableFact] + public async Task AuditLogIngestActor_CachedTelemetry_AppliesFilter() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + var trackedId = TrackedOperationId.New(); + var bigRequest = new string('d', 10 * 1024); + var audit = new AuditEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc), + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.CachedSubmit, + Status = AuditStatus.Submitted, + SourceSiteId = siteId, + CorrelationId = trackedId.Value, + RequestSummary = bigRequest, + PayloadTruncated = false, + }; + var siteCall = new SiteCall + { + TrackedOperationId = trackedId, + Channel = "ApiOutbound", + Target = "ERP.GetOrder", + SourceSite = siteId, + Status = "Submitted", + RetryCount = 0, + CreatedAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc), + UpdatedAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc), + IngestedAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc), + }; + + var sp = BuildServiceProvider(); + var actor = Sys.ActorOf(Props.Create(() => new AuditLogIngestActor( + sp, NullLogger.Instance))); + + actor.Tell( + new IngestCachedTelemetryCommand(new[] { new CachedTelemetryEntry(audit, siteCall) }), + TestActor); + ExpectMsg(TimeSpan.FromSeconds(15)); + + await using var read = CreateReadContext(); + var auditRow = await read.Set() + .SingleAsync(e => e.EventId == audit.EventId); + Assert.NotNull(auditRow.RequestSummary); + // Bundle C filter must run before the dual-write transaction + // commits, so the persisted AuditLog row carries the truncated + // payload. + Assert.Equal(8192, Encoding.UTF8.GetByteCount(auditRow.RequestSummary!)); + Assert.True(auditRow.PayloadTruncated); + } + } + + /// + /// IOptionsMonitor test double — returns the same snapshot on every read, + /// no change-token plumbing required for these tests. Mirrors the helper + /// used in TruncationTests. + /// + private sealed class StaticMonitor : IOptionsMonitor + { + private readonly AuditLogOptions _value; + + public StaticMonitor(AuditLogOptions value) => _value = value; + + public AuditLogOptions CurrentValue => _value; + + public AuditLogOptions Get(string? name) => _value; + + public IDisposable? OnChange(Action listener) => null; + } +}