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:
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