feat(auditlog): wire IAuditPayloadFilter into all writer paths (#23 M5)
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.
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
using Akka.Actor;
|
using Akka.Actor;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ScadaLink.AuditLog.Payload;
|
||||||
using ScadaLink.Commons.Entities.Audit;
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
using ScadaLink.Commons.Interfaces.Repositories;
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
using ScadaLink.Commons.Messages.Audit;
|
using ScadaLink.Commons.Messages.Audit;
|
||||||
@@ -114,8 +115,15 @@ public class AuditLogIngestActor : ReceiveActor
|
|||||||
// Resolve the repository for the whole batch — one DbContext per
|
// Resolve the repository for the whole batch — one DbContext per
|
||||||
// message, mirroring NotificationOutboxActor. The injected-repository
|
// message, mirroring NotificationOutboxActor. The injected-repository
|
||||||
// mode (Bundle D tests) skips the scope entirely.
|
// 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;
|
IServiceScope? scope = null;
|
||||||
IAuditLogRepository repository;
|
IAuditLogRepository repository;
|
||||||
|
IAuditPayloadFilter? filter = null;
|
||||||
if (_injectedRepository is not null)
|
if (_injectedRepository is not null)
|
||||||
{
|
{
|
||||||
repository = _injectedRepository;
|
repository = _injectedRepository;
|
||||||
@@ -124,6 +132,7 @@ public class AuditLogIngestActor : ReceiveActor
|
|||||||
{
|
{
|
||||||
scope = _serviceProvider!.CreateScope();
|
scope = _serviceProvider!.CreateScope();
|
||||||
repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
||||||
|
filter = scope.ServiceProvider.GetService<IAuditPayloadFilter>();
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -136,7 +145,11 @@ public class AuditLogIngestActor : ReceiveActor
|
|||||||
// repository hardening already swallows duplicate-key races,
|
// repository hardening already swallows duplicate-key races,
|
||||||
// so the same id arriving twice (site retry, reconciliation)
|
// so the same id arriving twice (site retry, reconciliation)
|
||||||
// is a silent no-op.
|
// 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);
|
await repository.InsertIfNotExistsAsync(ingested).ConfigureAwait(false);
|
||||||
accepted.Add(evt.EventId);
|
accepted.Add(evt.EventId);
|
||||||
}
|
}
|
||||||
@@ -185,6 +198,12 @@ public class AuditLogIngestActor : ReceiveActor
|
|||||||
var auditRepo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
var auditRepo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
||||||
var siteCallRepo = scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>();
|
var siteCallRepo = scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>();
|
||||||
var dbContext = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
var dbContext = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||||
|
// 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<IAuditPayloadFilter>();
|
||||||
|
|
||||||
foreach (var entry in cmd.Entries)
|
foreach (var entry in cmd.Entries)
|
||||||
{
|
{
|
||||||
@@ -199,7 +218,12 @@ public class AuditLogIngestActor : ReceiveActor
|
|||||||
// matching timestamps (debugging convenience, not a
|
// matching timestamps (debugging convenience, not a
|
||||||
// correctness invariant).
|
// correctness invariant).
|
||||||
var ingestedAt = DateTime.UtcNow;
|
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 };
|
var siteCallStamped = entry.SiteCall with { IngestedAtUtc = ingestedAt };
|
||||||
|
|
||||||
await auditRepo.InsertIfNotExistsAsync(auditStamped)
|
await auditRepo.InsertIfNotExistsAsync(auditStamped)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ScadaLink.AuditLog.Payload;
|
||||||
using ScadaLink.Commons.Entities.Audit;
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
using ScadaLink.Commons.Interfaces.Repositories;
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
using ScadaLink.Commons.Interfaces.Services;
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
@@ -40,11 +41,24 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
|
|||||||
{
|
{
|
||||||
private readonly IServiceProvider _services;
|
private readonly IServiceProvider _services;
|
||||||
private readonly ILogger<CentralAuditWriter> _logger;
|
private readonly ILogger<CentralAuditWriter> _logger;
|
||||||
|
private readonly IAuditPayloadFilter? _filter;
|
||||||
|
|
||||||
public CentralAuditWriter(IServiceProvider services, ILogger<CentralAuditWriter> logger)
|
/// <summary>
|
||||||
|
/// 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 <see cref="ServiceCollectionExtensions.AddAuditLog"/>.
|
||||||
|
/// </summary>
|
||||||
|
public CentralAuditWriter(
|
||||||
|
IServiceProvider services,
|
||||||
|
ILogger<CentralAuditWriter> logger,
|
||||||
|
IAuditPayloadFilter? filter = null)
|
||||||
{
|
{
|
||||||
_services = services ?? throw new ArgumentNullException(nameof(services));
|
_services = services ?? throw new ArgumentNullException(nameof(services));
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
_filter = filter;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -65,9 +79,14 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
|
|||||||
|
|
||||||
try
|
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();
|
await using var scope = _services.CreateAsyncScope();
|
||||||
var repo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
var repo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
||||||
var stamped = evt with { IngestedAtUtc = DateTime.UtcNow };
|
var stamped = filtered with { IngestedAtUtc = DateTime.UtcNow };
|
||||||
await repo.InsertIfNotExistsAsync(stamped, ct).ConfigureAwait(false);
|
await repo.InsertIfNotExistsAsync(stamped, ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -106,11 +106,16 @@ public static class ServiceCollectionExtensions
|
|||||||
// The script-thread surface is FallbackAuditWriter (primary + ring +
|
// The script-thread surface is FallbackAuditWriter (primary + ring +
|
||||||
// counter), not the raw SqliteAuditWriter — primary failures must NEVER
|
// counter), not the raw SqliteAuditWriter — primary failures must NEVER
|
||||||
// abort the user-facing action.
|
// 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<IAuditWriter>(sp => new FallbackAuditWriter(
|
services.AddSingleton<IAuditWriter>(sp => new FallbackAuditWriter(
|
||||||
primary: sp.GetRequiredService<SqliteAuditWriter>(),
|
primary: sp.GetRequiredService<SqliteAuditWriter>(),
|
||||||
ring: sp.GetRequiredService<RingBufferFallback>(),
|
ring: sp.GetRequiredService<RingBufferFallback>(),
|
||||||
failureCounter: sp.GetRequiredService<IAuditWriteFailureCounter>(),
|
failureCounter: sp.GetRequiredService<IAuditWriteFailureCounter>(),
|
||||||
logger: sp.GetRequiredService<ILogger<FallbackAuditWriter>>()));
|
logger: sp.GetRequiredService<ILogger<FallbackAuditWriter>>(),
|
||||||
|
filter: sp.GetRequiredService<IAuditPayloadFilter>()));
|
||||||
|
|
||||||
// ISiteStreamAuditClient: NoOp default. M6's reconciliation work brings
|
// ISiteStreamAuditClient: NoOp default. M6's reconciliation work brings
|
||||||
// the real gRPC-backed implementation (no site→central gRPC channel
|
// 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
|
// is intentionally distinct from IAuditWriter so site composition roots
|
||||||
// do not accidentally bind it; central composition roots that include
|
// do not accidentally bind it; central composition roots that include
|
||||||
// AddConfigurationDatabase get a working implementation transparently.
|
// AddConfigurationDatabase get a working implementation transparently.
|
||||||
services.AddSingleton<ICentralAuditWriter, CentralAuditWriter>();
|
// 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<ICentralAuditWriter>(sp => new CentralAuditWriter(
|
||||||
|
sp,
|
||||||
|
sp.GetRequiredService<ILogger<CentralAuditWriter>>(),
|
||||||
|
sp.GetRequiredService<IAuditPayloadFilter>()));
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ScadaLink.AuditLog.Payload;
|
||||||
using ScadaLink.Commons.Entities.Audit;
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
using ScadaLink.Commons.Interfaces.Services;
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
|
|
||||||
@@ -30,27 +31,48 @@ public sealed class FallbackAuditWriter : IAuditWriter
|
|||||||
private readonly RingBufferFallback _ring;
|
private readonly RingBufferFallback _ring;
|
||||||
private readonly IAuditWriteFailureCounter _failureCounter;
|
private readonly IAuditWriteFailureCounter _failureCounter;
|
||||||
private readonly ILogger<FallbackAuditWriter> _logger;
|
private readonly ILogger<FallbackAuditWriter> _logger;
|
||||||
|
private readonly IAuditPayloadFilter? _filter;
|
||||||
private readonly SemaphoreSlim _drainGate = new(1, 1);
|
private readonly SemaphoreSlim _drainGate = new(1, 1);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bundle C (M5-T6) wires the singleton <see cref="IAuditPayloadFilter"/>
|
||||||
|
/// 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
|
||||||
|
/// <see cref="ServiceCollectionExtensions.AddAuditLog"/> registration
|
||||||
|
/// always passes the real filter through.
|
||||||
|
/// </summary>
|
||||||
public FallbackAuditWriter(
|
public FallbackAuditWriter(
|
||||||
IAuditWriter primary,
|
IAuditWriter primary,
|
||||||
RingBufferFallback ring,
|
RingBufferFallback ring,
|
||||||
IAuditWriteFailureCounter failureCounter,
|
IAuditWriteFailureCounter failureCounter,
|
||||||
ILogger<FallbackAuditWriter> logger)
|
ILogger<FallbackAuditWriter> logger,
|
||||||
|
IAuditPayloadFilter? filter = null)
|
||||||
{
|
{
|
||||||
_primary = primary ?? throw new ArgumentNullException(nameof(primary));
|
_primary = primary ?? throw new ArgumentNullException(nameof(primary));
|
||||||
_ring = ring ?? throw new ArgumentNullException(nameof(ring));
|
_ring = ring ?? throw new ArgumentNullException(nameof(ring));
|
||||||
_failureCounter = failureCounter ?? throw new ArgumentNullException(nameof(failureCounter));
|
_failureCounter = failureCounter ?? throw new ArgumentNullException(nameof(failureCounter));
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_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)
|
public async Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(evt);
|
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
|
try
|
||||||
{
|
{
|
||||||
await _primary.WriteAsync(evt, ct).ConfigureAwait(false);
|
await _primary.WriteAsync(filtered, ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -62,8 +84,12 @@ public sealed class FallbackAuditWriter : IAuditWriter
|
|||||||
_failureCounter.Increment();
|
_failureCounter.Increment();
|
||||||
_logger.LogWarning(ex,
|
_logger.LogWarning(ex,
|
||||||
"Primary audit writer threw; routing EventId {EventId} to drop-oldest ring.",
|
"Primary audit writer threw; routing EventId {EventId} to drop-oldest ring.",
|
||||||
evt.EventId);
|
filtered.EventId);
|
||||||
_ring.TryEnqueue(evt);
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
301
tests/ScadaLink.AuditLog.Tests/Payload/FilterIntegrationTests.cs
Normal file
301
tests/ScadaLink.AuditLog.Tests/Payload/FilterIntegrationTests.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bundle C (M5-T6) integration tests verifying that the
|
||||||
|
/// <see cref="IAuditPayloadFilter"/> wires correctly into each of the three
|
||||||
|
/// writer entry points — <see cref="FallbackAuditWriter"/> on the site hot
|
||||||
|
/// path, <see cref="CentralAuditWriter"/> on the central direct-write path,
|
||||||
|
/// and <see cref="AuditLogIngestActor"/> on the site→central telemetry ingest
|
||||||
|
/// path (both the per-row <c>IngestAuditEventsCommand</c> handler and the
|
||||||
|
/// combined <c>IngestCachedTelemetryCommand</c> dual-write handler).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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 <c>PayloadTruncated=true</c>, 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
|
||||||
|
/// <see cref="DefaultAuditPayloadFilter"/> through every test so the
|
||||||
|
/// integration is real end-to-end, not a fake-filter assertion.
|
||||||
|
/// </remarks>
|
||||||
|
public class FilterIntegrationTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private static IAuditPayloadFilter NewDefaultFilter()
|
||||||
|
{
|
||||||
|
var monitor = Microsoft.Extensions.Options.Options.Create(new AuditLogOptions());
|
||||||
|
return new DefaultAuditPayloadFilter(
|
||||||
|
new StaticMonitor(monitor.Value),
|
||||||
|
NullLogger<DefaultAuditPayloadFilter>.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<SqliteAuditWriter>.Instance,
|
||||||
|
connectionStringOverride: $"Data Source={dataSource};Cache=Shared");
|
||||||
|
await using var _disposeSqlite = sqliteWriter;
|
||||||
|
|
||||||
|
var fallback = new FallbackAuditWriter(
|
||||||
|
sqliteWriter,
|
||||||
|
new RingBufferFallback(),
|
||||||
|
new NoOpAuditWriteFailureCounter(),
|
||||||
|
NullLogger<FallbackAuditWriter>.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<IAuditLogRepository>();
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddScoped(_ => repo);
|
||||||
|
services.AddSingleton(NewDefaultFilter());
|
||||||
|
var provider = services.BuildServiceProvider();
|
||||||
|
|
||||||
|
var writer = new CentralAuditWriter(
|
||||||
|
provider, NullLogger<CentralAuditWriter>.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<AuditEvent>(e =>
|
||||||
|
e.EventId == evt.EventId
|
||||||
|
&& e.RequestSummary != null
|
||||||
|
&& Encoding.UTF8.GetByteCount(e.RequestSummary) == 8192
|
||||||
|
&& e.PayloadTruncated == true),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- C1.3 + C1.4: AuditLogIngestActor applies the filter on both paths ---
|
||||||
|
|
||||||
|
public class IngestActorTests : TestKit, IClassFixture<MsSqlMigrationFixture>
|
||||||
|
{
|
||||||
|
private readonly MsSqlMigrationFixture _fixture;
|
||||||
|
|
||||||
|
public IngestActorTests(MsSqlMigrationFixture fixture)
|
||||||
|
{
|
||||||
|
_fixture = fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ScadaLinkDbContext CreateReadContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
|
||||||
|
.UseSqlServer(_fixture.ConnectionString)
|
||||||
|
.Options;
|
||||||
|
return new ScadaLinkDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NewSiteId() =>
|
||||||
|
"test-bundle-c1-filter-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build the IServiceProvider in the production-flavoured shape —
|
||||||
|
/// scoped repositories + a singleton <see cref="IAuditPayloadFilter"/>
|
||||||
|
/// resolved per-message from the actor's scope. Matches the
|
||||||
|
/// AddAuditLog registrations Bundle B established.
|
||||||
|
/// </summary>
|
||||||
|
private IServiceProvider BuildServiceProvider()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddDbContext<ScadaLinkDbContext>(opts =>
|
||||||
|
opts.UseSqlServer(_fixture.ConnectionString)
|
||||||
|
.ConfigureWarnings(w => w.Ignore(
|
||||||
|
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
|
||||||
|
services.AddScoped<IAuditLogRepository>(sp =>
|
||||||
|
new AuditLogRepository(sp.GetRequiredService<ScadaLinkDbContext>()));
|
||||||
|
services.AddScoped<ISiteCallAuditRepository>(sp =>
|
||||||
|
new SiteCallAuditRepository(sp.GetRequiredService<ScadaLinkDbContext>()));
|
||||||
|
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<AuditLogIngestActor>.Instance)));
|
||||||
|
|
||||||
|
actor.Tell(new IngestAuditEventsCommand(new[] { evt }), TestActor);
|
||||||
|
ExpectMsg<IngestAuditEventsReply>(TimeSpan.FromSeconds(15));
|
||||||
|
|
||||||
|
// Verify the persisted row was filtered before INSERT.
|
||||||
|
await using var read = CreateReadContext();
|
||||||
|
var row = await read.Set<AuditEvent>()
|
||||||
|
.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<AuditLogIngestActor>.Instance)));
|
||||||
|
|
||||||
|
actor.Tell(
|
||||||
|
new IngestCachedTelemetryCommand(new[] { new CachedTelemetryEntry(audit, siteCall) }),
|
||||||
|
TestActor);
|
||||||
|
ExpectMsg<IngestCachedTelemetryReply>(TimeSpan.FromSeconds(15));
|
||||||
|
|
||||||
|
await using var read = CreateReadContext();
|
||||||
|
var auditRow = await read.Set<AuditEvent>()
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IOptionsMonitor test double — returns the same snapshot on every read,
|
||||||
|
/// no change-token plumbing required for these tests. Mirrors the helper
|
||||||
|
/// used in <c>TruncationTests</c>.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class StaticMonitor : IOptionsMonitor<AuditLogOptions>
|
||||||
|
{
|
||||||
|
private readonly AuditLogOptions _value;
|
||||||
|
|
||||||
|
public StaticMonitor(AuditLogOptions value) => _value = value;
|
||||||
|
|
||||||
|
public AuditLogOptions CurrentValue => _value;
|
||||||
|
|
||||||
|
public AuditLogOptions Get(string? name) => _value;
|
||||||
|
|
||||||
|
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user