refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,207 @@
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Payload;
/// <summary>
/// Bundle B (M5-T4) tests for body regex redaction in
/// <see cref="DefaultAuditPayloadFilter"/>. The body-redactor stage runs
/// regex replace against RequestSummary / ResponseSummary / ErrorDetail /
/// Extra, replacing every match with <c>&lt;redacted&gt;</c>. Regexes come
/// from <see cref="AuditLogOptions.GlobalBodyRedactors"/> plus the per-target
/// <see cref="PerTargetRedactionOverride.AdditionalBodyRedactors"/>. Each
/// regex is compiled with a 50 ms timeout so catastrophic-backtracking
/// patterns trip a <see cref="System.Text.RegularExpressions.RegexMatchTimeoutException"/>;
/// when that happens the offending field is over-redacted with
/// <c>&lt;redacted: redactor error&gt;</c> and the
/// <see cref="IAuditRedactionFailureCounter"/> is incremented. The stage runs
/// BEFORE truncation.
/// </summary>
public class BodyRegexRedactionTests
{
private static IOptionsMonitor<AuditLogOptions> Monitor(AuditLogOptions? opts = null) =>
new StaticMonitor(opts ?? new AuditLogOptions());
private static DefaultAuditPayloadFilter Filter(
AuditLogOptions? opts = null,
IAuditRedactionFailureCounter? counter = null) =>
new(Monitor(opts), NullLogger<DefaultAuditPayloadFilter>.Instance, counter);
private static AuditEvent NewEvent(
AuditStatus status = AuditStatus.Delivered,
string? request = null,
string? response = null,
string? errorDetail = null,
string? extra = null,
string? target = null) => new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = status,
Target = target,
RequestSummary = request,
ResponseSummary = response,
ErrorDetail = errorDetail,
Extra = extra,
};
[Fact]
public void GlobalRegex_HunterPassword_Redacted()
{
var opts = new AuditLogOptions
{
GlobalBodyRedactors = new List<string> { "\"password\":\\s*\"[^\"]*\"" },
};
const string input = "{\"user\":\"alice\",\"password\":\"hunter2\"}";
var evt = NewEvent(request: input);
var result = Filter(opts).Apply(evt);
Assert.NotNull(result.RequestSummary);
Assert.Contains("<redacted>", result.RequestSummary);
Assert.DoesNotContain("hunter2", result.RequestSummary);
Assert.Contains("alice", result.RequestSummary);
}
[Fact]
public void PerTargetRegex_OnlyAppliedToMatchingTarget()
{
var opts = new AuditLogOptions
{
PerTargetOverrides = new Dictionary<string, PerTargetRedactionOverride>
{
["esg.A"] = new PerTargetRedactionOverride
{
AdditionalBodyRedactors = new List<string> { "SECRET-[A-Z0-9]+" },
},
},
};
const string input = "token=SECRET-XYZ123 normal-text";
var matchedEvt = NewEvent(request: input, target: "esg.A");
var matchedResult = Filter(opts).Apply(matchedEvt);
Assert.Contains("<redacted>", matchedResult.RequestSummary!);
Assert.DoesNotContain("SECRET-XYZ123", matchedResult.RequestSummary!);
var unmatchedEvt = NewEvent(request: input, target: "esg.B");
var unmatchedResult = Filter(opts).Apply(unmatchedEvt);
Assert.Equal(input, unmatchedResult.RequestSummary);
}
[Fact]
public void RegexThrowsTimeout_FieldBecomesRedactedMarker_CounterIncrements()
{
// Catastrophic backtracking pattern: alternation with overlapping
// groups + non-matching suffix forces the engine into exponential
// work that blows past the 50 ms timeout. Append a non-'a' character
// so the suffix anchor fails and the engine has to exhaust every
// permutation.
var opts = new AuditLogOptions
{
GlobalBodyRedactors = new List<string> { "^(a+)+$" },
};
// 30 'a's followed by '!' — small enough to keep the test fast, big
// enough to overflow the 50 ms regex timeout on every machine the CI
// grid runs on.
var input = new string('a', 30) + "!";
var counter = new CountingRedactionFailureCounter();
var evt = NewEvent(request: input);
var result = Filter(opts, counter).Apply(evt);
Assert.Equal("<redacted: redactor error>", result.RequestSummary);
Assert.True(counter.Count >= 1, $"expected counter >= 1, got {counter.Count}");
}
[Fact]
public void NoRegexConfigured_FieldUnchanged()
{
var opts = new AuditLogOptions(); // no GlobalBodyRedactors, no per-target
const string input = "{\"password\":\"hunter2\"}";
var evt = NewEvent(request: input);
var result = Filter(opts).Apply(evt);
Assert.Equal(input, result.RequestSummary);
}
[Fact]
public void RedactionAppliedBeforeTruncation()
{
// A pattern that matches a long secret in the body. The full input is
// > 8 KB so truncation must run. After redaction:
// * the marker survives the cap (redaction ran first),
// * the original secret bytes do NOT survive,
// * PayloadTruncated is set.
var opts = new AuditLogOptions
{
GlobalBodyRedactors = new List<string> { "SECRET-[A-Z0-9]+" },
};
var secret = "SECRET-ABCDEF123";
var padding = new string('x', 9 * 1024);
var input = secret + padding;
Assert.True(Encoding.UTF8.GetByteCount(input) > 8192);
var evt = NewEvent(AuditStatus.Delivered, request: input);
var result = Filter(opts).Apply(evt);
Assert.NotNull(result.RequestSummary);
Assert.True(Encoding.UTF8.GetByteCount(result.RequestSummary!) <= 8192);
Assert.Contains("<redacted>", result.RequestSummary);
Assert.DoesNotContain(secret, result.RequestSummary);
Assert.True(result.PayloadTruncated);
}
[Fact]
public void CatastrophicBacktrackingRegex_AtCompileTime_RejectedAtStartup()
{
// .NET's regex engine has no compile-time detection for catastrophic
// backtracking (only structural validation), so the filter's
// protection is RUNTIME — the 50 ms per-match timeout. We assert the
// safety net behaviour: a known evil pattern compiles cleanly but
// matches time out at runtime, the field is over-redacted, and the
// failure counter is incremented. Future engines that DO support
// compile-time analysis can tighten this further; the contract here
// is that the user-facing action is never aborted.
var evilPattern = "^(a+)+$";
var opts = new AuditLogOptions
{
GlobalBodyRedactors = new List<string> { evilPattern },
};
var input = new string('a', 30) + "!";
var counter = new CountingRedactionFailureCounter();
var evt = NewEvent(request: input);
var result = Filter(opts, counter).Apply(evt);
Assert.Equal("<redacted: redactor error>", result.RequestSummary);
Assert.True(counter.Count >= 1);
}
/// <summary>Test double that counts increments.</summary>
private sealed class CountingRedactionFailureCounter : IAuditRedactionFailureCounter
{
private int _count;
public int Count => _count;
public void Increment() => System.Threading.Interlocked.Increment(ref _count);
}
/// <summary>IOptionsMonitor test double — returns the same snapshot on every read.</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;
}
}
@@ -0,0 +1,303 @@
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 ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
namespace ZB.MOM.WW.ScadaBridge.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,
new FakeNodeIdentityProvider(),
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 ScadaBridgeDbContext CreateReadContext()
{
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer(_fixture.ConnectionString)
.Options;
return new ScadaBridgeDbContext(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<ScadaBridgeDbContext>(opts =>
opts.UseSqlServer(_fixture.ConnectionString)
.ConfigureWarnings(w => w.Ignore(
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
services.AddScoped<IAuditLogRepository>(sp =>
new AuditLogRepository(sp.GetRequiredService<ScadaBridgeDbContext>()));
services.AddScoped<ISiteCallAuditRepository>(sp =>
new SiteCallAuditRepository(sp.GetRequiredService<ScadaBridgeDbContext>()));
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;
}
}
@@ -0,0 +1,217 @@
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Payload;
/// <summary>
/// Bundle B (M5-T3) tests for <see cref="DefaultAuditPayloadFilter"/> HTTP header
/// redaction. Redaction parses <see cref="AuditEvent.RequestSummary"/> /
/// <see cref="AuditEvent.ResponseSummary"/> as JSON of shape
/// <c>{"headers": {"name": "value", ...}, "body": "..."}</c>, replaces values
/// whose header NAME (case-insensitive) is in
/// <see cref="AuditLogOptions.HeaderRedactList"/> with <c>"&lt;redacted&gt;"</c>,
/// and re-serialises. Non-JSON inputs pass through unchanged (no-op for
/// emitters that have not yet adopted the convention). The stage runs BEFORE
/// truncation so the redaction marker survives the cap.
/// </summary>
public class HeaderRedactionTests
{
private static IOptionsMonitor<AuditLogOptions> Monitor(AuditLogOptions? opts = null) =>
new StaticMonitor(opts ?? new AuditLogOptions());
private static DefaultAuditPayloadFilter Filter(AuditLogOptions? opts = null) =>
new(Monitor(opts), NullLogger<DefaultAuditPayloadFilter>.Instance);
private static AuditEvent NewEvent(
AuditStatus status = AuditStatus.Delivered,
string? request = null,
string? response = null) => new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = status,
RequestSummary = request,
ResponseSummary = response,
};
private static string BuildSummary(IDictionary<string, string> headers, string body)
{
// Serialize via System.Text.Json so we get a representative shape.
return JsonSerializer.Serialize(new
{
headers = headers,
body = body,
});
}
private static IDictionary<string, JsonElement> ParseSummary(string? summary)
{
Assert.NotNull(summary);
using var doc = JsonDocument.Parse(summary!);
var dict = new Dictionary<string, JsonElement>();
foreach (var property in doc.RootElement.EnumerateObject())
{
dict[property.Name] = property.Value.Clone();
}
return dict;
}
[Fact]
public void HeaderRedaction_AuthorizationBearer_Redacted()
{
var headers = new Dictionary<string, string>
{
["Authorization"] = "Bearer secret-token-xyz",
["Content-Type"] = "application/json",
};
var input = BuildSummary(headers, "hello");
var evt = NewEvent(request: input);
var result = Filter().Apply(evt);
var parsed = ParseSummary(result.RequestSummary);
var resultHeaders = parsed["headers"];
Assert.Equal("<redacted>", resultHeaders.GetProperty("Authorization").GetString());
}
[Fact]
public void HeaderRedaction_CaseInsensitive_LowercaseAuthorization_Redacted()
{
var headers = new Dictionary<string, string>
{
["authorization"] = "Bearer secret-token-xyz",
};
var input = BuildSummary(headers, "hello");
var evt = NewEvent(request: input);
var result = Filter().Apply(evt);
var parsed = ParseSummary(result.RequestSummary);
var resultHeaders = parsed["headers"];
Assert.Equal("<redacted>", resultHeaders.GetProperty("authorization").GetString());
}
[Fact]
public void HeaderRedaction_CustomRedactList_RedactsCustomHeaderName()
{
var opts = new AuditLogOptions
{
HeaderRedactList = new List<string> { "X-Custom-Secret" },
};
var headers = new Dictionary<string, string>
{
["X-Custom-Secret"] = "topsecret",
["Authorization"] = "Bearer keep-me", // not in list anymore
};
var input = BuildSummary(headers, "hi");
var evt = NewEvent(request: input);
var result = Filter(opts).Apply(evt);
var parsed = ParseSummary(result.RequestSummary);
var resultHeaders = parsed["headers"];
Assert.Equal("<redacted>", resultHeaders.GetProperty("X-Custom-Secret").GetString());
// Authorization no longer listed -> preserved verbatim.
Assert.Equal("Bearer keep-me", resultHeaders.GetProperty("Authorization").GetString());
}
[Fact]
public void HeaderRedaction_NonJson_RequestSummary_Unchanged()
{
const string input = "this is not JSON at all";
var evt = NewEvent(request: input);
var result = Filter().Apply(evt);
Assert.Equal(input, result.RequestSummary);
}
[Fact]
public void HeaderRedaction_NoHeadersField_Unchanged()
{
var input = JsonSerializer.Serialize(new { body = "only a body, no headers" });
var evt = NewEvent(request: input);
var result = Filter().Apply(evt);
// The stage may re-serialise but the content must be semantically identical.
var parsed = ParseSummary(result.RequestSummary);
Assert.Equal("only a body, no headers", parsed["body"].GetString());
Assert.False(parsed.ContainsKey("headers"));
}
[Fact]
public void HeaderRedaction_Other_Headers_Preserved()
{
var headers = new Dictionary<string, string>
{
["Authorization"] = "Bearer secret",
["Content-Type"] = "application/json",
["X-Request-Id"] = "abc-123",
["Accept"] = "application/json",
};
var input = BuildSummary(headers, "payload");
var evt = NewEvent(request: input);
var result = Filter().Apply(evt);
var parsed = ParseSummary(result.RequestSummary);
var resultHeaders = parsed["headers"];
Assert.Equal("<redacted>", resultHeaders.GetProperty("Authorization").GetString());
Assert.Equal("application/json", resultHeaders.GetProperty("Content-Type").GetString());
Assert.Equal("abc-123", resultHeaders.GetProperty("X-Request-Id").GetString());
Assert.Equal("application/json", resultHeaders.GetProperty("Accept").GetString());
}
[Fact]
public void HeaderRedaction_AppliedBeforeTruncation()
{
// Build a summary whose Authorization header value is enormous AND whose
// body padding pushes the total beyond the 8 KB cap. After redaction the
// Authorization value becomes "<redacted>" — then truncation caps the
// re-serialised string. Result must:
// * carry "<redacted>" (header redaction ran first),
// * NOT carry the original secret bytes (proves redaction won, not order swap),
// * be capped at the configured DefaultCapBytes,
// * have PayloadTruncated == true.
const string secret = "SUPER-SECRET-TOKEN-DO-NOT-LEAK";
var headers = new Dictionary<string, string>
{
["Authorization"] = "Bearer " + secret,
};
var body = new string('x', 9 * 1024);
var input = BuildSummary(headers, body);
Assert.True(Encoding.UTF8.GetByteCount(input) > 8192);
var evt = NewEvent(AuditStatus.Delivered, request: input);
var result = Filter().Apply(evt);
Assert.NotNull(result.RequestSummary);
Assert.True(Encoding.UTF8.GetByteCount(result.RequestSummary!) <= 8192);
Assert.Contains("<redacted>", result.RequestSummary);
Assert.DoesNotContain(secret, result.RequestSummary);
Assert.True(result.PayloadTruncated);
}
/// <summary>
/// IOptionsMonitor test double — returns the same snapshot on every read,
/// no change-token plumbing required for these tests.
/// </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;
}
}
@@ -0,0 +1,133 @@
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Payload;
/// <summary>
/// Pins the docs/plans/2026-05-23-inbound-api-full-response-audit-design.md
/// inbound carve-out: ApiInbound rows use InboundMaxBytes (default 1 MiB) for
/// RequestSummary / ResponseSummary truncation, NOT DefaultCapBytes /
/// ErrorCapBytes. Other channels keep the existing caps.
/// </summary>
/// <remarks>
/// Uses a file-local <see cref="StaticMonitor"/> helper mirroring the
/// convention in the sibling Payload tests (TruncationTests,
/// FilterIntegrationTests, BodyRegexRedactionTests, etc.) — the
/// <c>TestOptionsMonitor&lt;T&gt;</c> helper referenced by the plan is a
/// private nested class inside <c>AuditLogOptionsBindingTests</c> and thus
/// not reachable from this file.
/// </remarks>
public class InboundChannelCapTests
{
private static AuditEvent MakeInbound(
AuditStatus status,
string? request = null,
string? response = null) =>
new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiInbound,
Kind = AuditKind.InboundRequest,
Status = status,
RequestSummary = request,
ResponseSummary = response,
};
[Fact]
public void ApiInbound_Delivered_RequestBody_BelowInboundMaxBytes_NotTruncated()
{
// Body well above the legacy 8 KiB default cap but under the 1 MiB
// inbound ceiling — must NOT truncate.
var body = new string('a', 100_000);
var opts = new AuditLogOptions(); // defaults
var filter = new DefaultAuditPayloadFilter(
new StaticMonitor(opts),
NullLogger<DefaultAuditPayloadFilter>.Instance);
var result = filter.Apply(MakeInbound(AuditStatus.Delivered, request: body));
Assert.False(result.PayloadTruncated);
Assert.Equal(100_000, Encoding.UTF8.GetByteCount(result.RequestSummary!));
}
[Fact]
public void ApiInbound_Delivered_ResponseBody_BelowInboundMaxBytes_NotTruncated()
{
var body = new string('a', 100_000);
var opts = new AuditLogOptions();
var filter = new DefaultAuditPayloadFilter(
new StaticMonitor(opts),
NullLogger<DefaultAuditPayloadFilter>.Instance);
var result = filter.Apply(MakeInbound(AuditStatus.Delivered, response: body));
Assert.False(result.PayloadTruncated);
Assert.Equal(100_000, Encoding.UTF8.GetByteCount(result.ResponseSummary!));
}
[Fact]
public void ApiInbound_Failed_BodyAboveInboundMaxBytes_TruncatedToInboundMaxBytes()
{
// Even on error rows, the inbound cap is InboundMaxBytes (NOT ErrorCapBytes).
var opts = new AuditLogOptions { InboundMaxBytes = 16_384 };
var oversized = new string('z', 50_000);
var filter = new DefaultAuditPayloadFilter(
new StaticMonitor(opts),
NullLogger<DefaultAuditPayloadFilter>.Instance);
var result = filter.Apply(MakeInbound(AuditStatus.Failed, response: oversized));
Assert.True(result.PayloadTruncated);
Assert.True(Encoding.UTF8.GetByteCount(result.ResponseSummary!) <= 16_384);
}
[Fact]
public void ApiOutbound_StillUsesDefaultCap_NotInboundMaxBytes()
{
// Regression guard: lifting the inbound cap MUST NOT change other
// channels. An ApiOutbound 100 KB body still hits the 8 KiB cap.
var opts = new AuditLogOptions();
var body = new string('a', 100_000);
var filter = new DefaultAuditPayloadFilter(
new StaticMonitor(opts),
NullLogger<DefaultAuditPayloadFilter>.Instance);
var evt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
RequestSummary = body,
};
var result = filter.Apply(evt);
Assert.True(result.PayloadTruncated);
Assert.True(Encoding.UTF8.GetByteCount(result.RequestSummary!) <= opts.DefaultCapBytes);
}
/// <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>, <c>FilterIntegrationTests</c>, etc.
/// </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;
}
}
@@ -0,0 +1,59 @@
using System.Linq;
using System.Reflection;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Payload;
/// <summary>
/// Bundle A (M5-T1) contract test for <see cref="IAuditPayloadFilter"/>. The
/// interface is the seam between event construction and writer persistence;
/// later bundles register the production implementation as a singleton and
/// invoke it from the site/central writer paths. We pin the surface area here
/// via reflection so accidental signature drift breaks the build before the
/// downstream wiring goes red.
/// </summary>
public class PayloadFilterContractTests
{
[Fact]
public void Interface_Exists_InPayloadNamespace()
{
var type = typeof(IAuditPayloadFilter);
Assert.True(type.IsInterface, "IAuditPayloadFilter must be an interface");
Assert.Equal("ZB.MOM.WW.ScadaBridge.AuditLog.Payload", type.Namespace);
}
[Fact]
public void Apply_Method_HasDocumentedSignature()
{
var type = typeof(IAuditPayloadFilter);
var method = type.GetMethod(
"Apply",
BindingFlags.Instance | BindingFlags.Public,
binder: null,
types: new[] { typeof(AuditEvent) },
modifiers: null);
Assert.NotNull(method);
Assert.Equal(typeof(AuditEvent), method!.ReturnType);
var parameters = method.GetParameters();
Assert.Single(parameters);
Assert.Equal("rawEvent", parameters[0].Name);
Assert.Equal(typeof(AuditEvent), parameters[0].ParameterType);
}
[Fact]
public void Interface_DeclaresExactlyOneMethod()
{
var type = typeof(IAuditPayloadFilter);
var methods = type.GetMethods(BindingFlags.Instance | BindingFlags.Public)
.Where(m => !m.IsSpecialName)
.ToArray();
Assert.Single(methods);
Assert.Equal("Apply", methods[0].Name);
}
}
@@ -0,0 +1,270 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Payload;
/// <summary>
/// Bundle D (M5-T10) safety-net edge cases for
/// <see cref="DefaultAuditPayloadFilter"/>. Bundle B already pinned the
/// happy-path safety net (catastrophic-backtracking timeout →
/// <c>&lt;redacted: redactor error&gt;</c> + counter bump); this fixture covers
/// the pathological / config-mistake corners that production operators will
/// hit when typoing a regex or shipping a half-baked redactor list.
/// </summary>
/// <remarks>
/// <para>
/// The invariants under test:
/// </para>
/// <list type="bullet">
/// <item>An UNCOMPILABLE pattern (e.g. <c>[unclosed</c>) is logged at warning
/// on first encounter and cached as invalid so it never throws again,
/// but the redactor-failure COUNTER is not bumped at bind time —
/// per the contract on <see cref="IAuditRedactionFailureCounter"/>
/// the counter tracks RUNTIME redaction failures only.</item>
/// <item>One throwing regex in the middle of a list does NOT poison the
/// other patterns — the filter stops at the failing pattern,
/// over-redacts the offending field, but lets every other field keep
/// the prior cleanly-redacted state and lets the rest of the writer
/// pipeline run.</item>
/// <item>A live config change that introduces a broken pattern does not
/// crash the filter — the bad pattern is silently dropped (logged once)
/// and the still-valid patterns continue to redact normally.</item>
/// </list>
/// </remarks>
public class RedactionSafetyNetTests
{
private static IOptionsMonitor<AuditLogOptions> Monitor(AuditLogOptions? opts = null) =>
new StaticMonitor(opts ?? new AuditLogOptions());
private static AuditEvent NewEvent(
AuditStatus status = AuditStatus.Delivered,
string? request = null,
string? response = null,
string? errorDetail = null,
string? extra = null,
string? target = null) => new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = status,
Target = target,
RequestSummary = request,
ResponseSummary = response,
ErrorDetail = errorDetail,
Extra = extra,
};
[Fact]
public void RegexNotCompilable_AtBindTime_LoggedAndSkipped()
{
// `[unclosed` is a structurally invalid character class — the .NET
// regex engine throws ArgumentException at compile time. We assert:
// * the filter does NOT throw,
// * the OTHER (valid) pattern still redacts hunter2,
// * the failure counter is NOT incremented at compile time
// (it tracks runtime redaction failures only),
// * a warning is logged exactly once.
const string badPattern = "[unclosed";
const string goodPattern = "\"password\":\\s*\"[^\"]*\"";
var opts = new AuditLogOptions
{
GlobalBodyRedactors = new List<string> { badPattern, goodPattern },
};
var counter = new CountingRedactionFailureCounter();
var spy = new SpyLogger<DefaultAuditPayloadFilter>();
var filter = new DefaultAuditPayloadFilter(Monitor(opts), spy, counter);
var evt = NewEvent(request: "{\"user\":\"alice\",\"password\":\"hunter2\"}");
var result = filter.Apply(evt);
Assert.NotNull(result.RequestSummary);
Assert.DoesNotContain("hunter2", result.RequestSummary);
Assert.Contains("<redacted>", result.RequestSummary);
Assert.Equal(0, counter.Count);
// Apply twice — the invalid-pattern compile must run AT MOST once;
// the sentinel-cache entry stops repeat compile attempts.
_ = filter.Apply(evt);
var badPatternWarnings = spy.Entries
.Where(e => e.Level == LogLevel.Warning && e.Message.Contains(badPattern))
.Count();
Assert.Equal(1, badPatternWarnings);
}
[Fact]
public void MultipleRedactors_OneThrows_OthersStillApply_ToOtherFields()
{
// Pattern set: [valid-A, evil, valid-B]. The evil pattern is
// catastrophic-backtracking on the RequestSummary input (all-'a's +
// mismatching suffix) — that field is over-redacted with the error
// marker as soon as evil throws. ResponseSummary is processed
// INDEPENDENTLY; its input does not trigger evil's backtracking, so
// valid-A and valid-B both still apply on that field. This proves a
// per-field redactor failure does not poison the rest of the writer
// call (the SQL-param stage, the truncation stage, and the other
// fields all continue normally).
const string validA = "SECRET-[A-Z0-9]+";
const string evil = "^(a+)+$"; // catastrophic on long all-'a' string
const string validB = "PIN-\\d{4}";
var opts = new AuditLogOptions
{
GlobalBodyRedactors = new List<string> { validA, evil, validB },
};
var counter = new CountingRedactionFailureCounter();
var filter = new DefaultAuditPayloadFilter(
Monitor(opts),
NullLogger<DefaultAuditPayloadFilter>.Instance,
counter);
// Request: ALL 'a's + a non-'a' suffix character. valid-A does not
// match (no SECRET-X prefix), so the buffer reaches `evil` untouched
// and triggers the backtracking explosion.
var request = new string('a', 30) + "!";
// Response: short, mismatches the evil pattern cleanly (no
// backtracking), so both valid-A and valid-B run and redact.
const string response = "SECRET-ABC456 PIN-9999 other-text";
var result = filter.Apply(NewEvent(request: request, response: response));
// RequestSummary: over-redacted (evil pattern threw).
Assert.Equal("<redacted: redactor error>", result.RequestSummary);
Assert.True(counter.Count >= 1, $"expected counter >= 1, got {counter.Count}");
// ResponseSummary: clean — both valid regexes still applied; the evil
// one ran without throwing on this short input.
Assert.NotNull(result.ResponseSummary);
Assert.DoesNotContain("SECRET-ABC456", result.ResponseSummary);
Assert.DoesNotContain("PIN-9999", result.ResponseSummary);
Assert.Contains("<redacted>", result.ResponseSummary);
Assert.Contains("other-text", result.ResponseSummary);
}
// Edge case 3 (RedactorReturnsNonStringExceptionType) intentionally
// skipped — the brief permits dropping it: there is no portable way to
// artificially trigger an OutOfMemoryException inside System.Text.RegularExpressions
// from a unit test without writing native interop, and the existing
// per-stage try/catch already covers Exception (which OOM and similar
// would derive from). Bundle B's RegexThrowsTimeout coverage exercises
// the same catch path with a deterministic trigger.
[Fact]
public void ConfigChange_WithBadRegex_LiveTrafficKeepsApplyingValidRegexes()
{
// Initial config: one valid global redactor — hunter2 is redacted.
// Reload: ADD a malformed pattern alongside the original. Per the
// safety contract, the bad pattern is logged + skipped, the original
// valid pattern keeps redacting, and the filter NEVER throws on the
// hot path. The counter must not be bumped at reload time (the
// CompiledRegex sentinel covers the bind error before runtime even
// sees it).
var monitor = new MutableMonitor(new AuditLogOptions
{
GlobalBodyRedactors = new List<string> { "\"password\":\\s*\"[^\"]*\"" },
});
var counter = new CountingRedactionFailureCounter();
var spy = new SpyLogger<DefaultAuditPayloadFilter>();
var filter = new DefaultAuditPayloadFilter(monitor, spy, counter);
var evt = NewEvent(request: "{\"user\":\"alice\",\"password\":\"hunter2\"}");
var before = filter.Apply(evt);
Assert.DoesNotContain("hunter2", before.RequestSummary!);
// Reload: malformed pattern added to the list.
monitor.Set(new AuditLogOptions
{
GlobalBodyRedactors = new List<string>
{
"\"password\":\\s*\"[^\"]*\"",
"[unclosed",
},
});
var after = filter.Apply(evt);
Assert.NotNull(after.RequestSummary);
Assert.DoesNotContain("hunter2", after.RequestSummary);
Assert.Contains("<redacted>", after.RequestSummary);
Assert.Equal(0, counter.Count);
// Compile-time warning logged for the broken pattern.
Assert.Contains(
spy.Entries,
e => e.Level == LogLevel.Warning && e.Message.Contains("[unclosed"));
}
/// <summary>Counts <see cref="IAuditRedactionFailureCounter.Increment"/> calls.</summary>
private sealed class CountingRedactionFailureCounter : IAuditRedactionFailureCounter
{
private int _count;
public int Count => _count;
public void Increment() => System.Threading.Interlocked.Increment(ref _count);
}
/// <summary>IOptionsMonitor test double — returns the same snapshot on every read.</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;
}
/// <summary>
/// IOptionsMonitor test double that supports a live <see cref="Set"/> —
/// mirrors the helper used in
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration.AuditLogOptionsBindingTests"/>;
/// kept private here so the safety-net test file remains self-contained.
/// </summary>
private sealed class MutableMonitor : IOptionsMonitor<AuditLogOptions>
{
private AuditLogOptions _current;
public MutableMonitor(AuditLogOptions initial) => _current = initial;
public AuditLogOptions CurrentValue => _current;
public AuditLogOptions Get(string? name) => _current;
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
public void Set(AuditLogOptions value) => _current = value;
}
/// <summary>
/// Minimal ILogger that records each formatted log line so tests can
/// assert on the compile-time warning emission contract — counting
/// warnings and grepping the message text.
/// </summary>
private sealed class SpyLogger<T> : ILogger<T>
{
private readonly List<LogEntry> _entries = new();
public IReadOnlyList<LogEntry> Entries
{
get { lock (_entries) return _entries.ToArray(); }
}
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
var msg = formatter(state, exception);
lock (_entries) _entries.Add(new LogEntry(logLevel, msg));
}
private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
public void Dispose() { }
}
}
public sealed record LogEntry(LogLevel Level, string Message);
}
@@ -0,0 +1,212 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Payload;
/// <summary>
/// Bundle B (M5-T5) tests for SQL parameter redaction in
/// <see cref="DefaultAuditPayloadFilter"/>. M4 Bundle A's
/// <c>AuditingDbCommand</c> emits <c>RequestSummary</c> as
/// <c>{"sql":"...","parameters":{"@name":"value", ...}}</c>; the SQL-parameter
/// redactor parses this shape on
/// <see cref="AuditChannel.DbOutbound"/> rows, replaces values whose key
/// matches the configured case-insensitive regex with <c>&lt;redacted&gt;</c>,
/// and re-serialises. Default behaviour with no opt-in: parameter values are
/// captured verbatim. Connection lookup uses the connection-name prefix of
/// <see cref="AuditEvent.Target"/> (everything before the first <c>.</c>) so
/// the same per-connection regex applies regardless of the SQL-snippet suffix
/// that <c>AuditingDbCommand</c> appends to disambiguate rows.
/// </summary>
public class SqlParamRedactionTests
{
private static IOptionsMonitor<AuditLogOptions> Monitor(AuditLogOptions? opts = null) =>
new StaticMonitor(opts ?? new AuditLogOptions());
private static DefaultAuditPayloadFilter Filter(AuditLogOptions? opts = null) =>
new(Monitor(opts), NullLogger<DefaultAuditPayloadFilter>.Instance);
private static AuditEvent NewDbEvent(string target, string requestSummary) => new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.DbOutbound,
Kind = AuditKind.DbWrite,
Status = AuditStatus.Delivered,
Target = target,
RequestSummary = requestSummary,
};
/// <summary>
/// Build a RequestSummary in the exact shape M4's <c>AuditingDbCommand</c>
/// emits — hand-rolled JSON with <c>"sql"</c> + <c>"parameters"</c> keys.
/// Tests depend on this format; if AuditingDbCommand ever changes, this
/// helper updates in lockstep.
/// </summary>
private static string DbRequestSummary(string sql, params (string name, string value)[] parameters)
{
var sb = new System.Text.StringBuilder();
sb.Append("{\"sql\":\"").Append(sql).Append('"');
if (parameters.Length > 0)
{
sb.Append(",\"parameters\":{");
for (var i = 0; i < parameters.Length; i++)
{
if (i > 0) sb.Append(',');
sb.Append('"').Append(parameters[i].name).Append("\":\"")
.Append(parameters[i].value).Append('"');
}
sb.Append('}');
}
sb.Append('}');
return sb.ToString();
}
[Fact]
public void NoOptIn_ParamsVerbatim_Unchanged()
{
var input = DbRequestSummary(
"INSERT INTO Users (Name, Token) VALUES (@name, @token)",
("@name", "Alice"),
("@token", "secret-xyz"));
var evt = NewDbEvent("PrimaryDb.INSERT INTO Users", input);
var result = Filter().Apply(evt);
Assert.Equal(input, result.RequestSummary);
}
[Fact]
public void OptInRegex_AtToken_OrAtApikey_RedactsThoseValues_KeepsOthers()
{
var opts = new AuditLogOptions
{
PerTargetOverrides = new Dictionary<string, PerTargetRedactionOverride>
{
["PrimaryDb"] = new PerTargetRedactionOverride
{
RedactSqlParamsMatching = "^@(token|apikey)$",
},
},
};
var input = DbRequestSummary(
"INSERT INTO Users (Name, Token, ApiKey) VALUES (@name, @token, @apikey)",
("@name", "Alice"),
("@token", "secret-xyz"),
("@apikey", "k-987"));
var evt = NewDbEvent("PrimaryDb.INSERT INTO Users", input);
var result = Filter(opts).Apply(evt);
Assert.NotNull(result.RequestSummary);
Assert.Contains("\"@name\":\"Alice\"", result.RequestSummary);
Assert.Contains("\"@token\":\"<redacted>\"", result.RequestSummary);
Assert.Contains("\"@apikey\":\"<redacted>\"", result.RequestSummary);
Assert.DoesNotContain("secret-xyz", result.RequestSummary);
Assert.DoesNotContain("k-987", result.RequestSummary);
}
[Fact]
public void RegexCaseInsensitive_MatchesParamNames()
{
var opts = new AuditLogOptions
{
PerTargetOverrides = new Dictionary<string, PerTargetRedactionOverride>
{
["PrimaryDb"] = new PerTargetRedactionOverride
{
RedactSqlParamsMatching = "token",
},
},
};
var input = DbRequestSummary(
"UPDATE x SET Token = @TOKEN",
("@TOKEN", "uppercased-secret"));
var evt = NewDbEvent("PrimaryDb.UPDATE x SET Token", input);
var result = Filter(opts).Apply(evt);
Assert.NotNull(result.RequestSummary);
Assert.Contains("\"@TOKEN\":\"<redacted>\"", result.RequestSummary);
Assert.DoesNotContain("uppercased-secret", result.RequestSummary);
}
[Fact]
public void NonDbOutboundChannel_NotAffected()
{
// ApiOutbound row whose RequestSummary happens to look like the
// DbOutbound JSON shape (worst-case false positive). The SQL
// redactor must NOT touch it — channel guards the stage.
var opts = new AuditLogOptions
{
PerTargetOverrides = new Dictionary<string, PerTargetRedactionOverride>
{
["PrimaryDb"] = new PerTargetRedactionOverride
{
RedactSqlParamsMatching = "^@token$",
},
},
};
var input = DbRequestSummary(
"SELECT @token",
("@token", "should-survive"));
var evt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
Target = "PrimaryDb.SELECT", // doesn't matter — channel guards
RequestSummary = input,
};
var result = Filter(opts).Apply(evt);
Assert.Equal(input, result.RequestSummary);
}
[Fact]
public void PerTargetSetting_MatchesByTarget()
{
// Two connections — A is configured to redact tokens, B is not. Same
// payload through each must yield different results.
var opts = new AuditLogOptions
{
PerTargetOverrides = new Dictionary<string, PerTargetRedactionOverride>
{
["ConnA"] = new PerTargetRedactionOverride
{
RedactSqlParamsMatching = "^@token$",
},
},
};
var input = DbRequestSummary(
"SELECT @token",
("@token", "the-secret"));
var aEvt = NewDbEvent("ConnA.SELECT @token", input);
var bEvt = NewDbEvent("ConnB.SELECT @token", input);
var aResult = Filter(opts).Apply(aEvt);
var bResult = Filter(opts).Apply(bEvt);
Assert.Contains("<redacted>", aResult.RequestSummary!);
Assert.DoesNotContain("the-secret", aResult.RequestSummary!);
Assert.Equal(input, bResult.RequestSummary);
}
/// <summary>IOptionsMonitor test double.</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;
}
}
@@ -0,0 +1,226 @@
using System.Linq;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Payload;
/// <summary>
/// Bundle A (M5-T2) tests for <see cref="DefaultAuditPayloadFilter"/> truncation.
/// The filter caps RequestSummary / ResponseSummary / ErrorDetail / Extra at
/// <see cref="AuditLogOptions.DefaultCapBytes"/> (8 KiB) on success rows and
/// <see cref="AuditLogOptions.ErrorCapBytes"/> (64 KiB) on error rows. "Error
/// row" = <see cref="AuditEvent.Status"/> NOT IN (<c>Delivered</c>,
/// <c>Submitted</c>, <c>Forwarded</c>). Truncation must respect UTF-8 character
/// boundaries (never split a multi-byte sequence mid-character) and must set
/// <see cref="AuditEvent.PayloadTruncated"/> true when any field is shortened.
/// </summary>
public class TruncationTests
{
private static IOptionsMonitor<AuditLogOptions> Monitor(AuditLogOptions? opts = null)
{
var snapshot = opts ?? new AuditLogOptions();
return new StaticMonitor(snapshot);
}
private static DefaultAuditPayloadFilter Filter(AuditLogOptions? opts = null) =>
new(Monitor(opts), NullLogger<DefaultAuditPayloadFilter>.Instance);
private static AuditEvent NewEvent(
AuditStatus status = AuditStatus.Delivered,
string? request = null,
string? response = null,
string? errorDetail = null,
string? extra = null,
bool payloadTruncated = false) => new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = status,
RequestSummary = request,
ResponseSummary = response,
ErrorDetail = errorDetail,
Extra = extra,
PayloadTruncated = payloadTruncated,
};
[Fact]
public void SuccessRow_10KB_RequestSummary_TruncatedTo8KB_PayloadTruncatedTrue()
{
var input = new string('a', 10 * 1024);
var evt = NewEvent(AuditStatus.Delivered, request: input);
var result = Filter().Apply(evt);
Assert.NotNull(result.RequestSummary);
Assert.Equal(8192, Encoding.UTF8.GetByteCount(result.RequestSummary!));
Assert.True(result.PayloadTruncated);
}
[Fact]
public void ErrorRow_10KB_RequestSummary_NotTruncated_UnderErrorCap()
{
var input = new string('b', 10 * 1024);
var evt = NewEvent(AuditStatus.Failed, request: input);
var result = Filter().Apply(evt);
Assert.Equal(input, result.RequestSummary);
Assert.False(result.PayloadTruncated);
}
[Fact]
public void ErrorRow_70KB_RequestSummary_TruncatedTo64KB_PayloadTruncatedTrue()
{
var input = new string('c', 70 * 1024);
var evt = NewEvent(AuditStatus.Failed, request: input);
var result = Filter().Apply(evt);
Assert.NotNull(result.RequestSummary);
Assert.Equal(65536, Encoding.UTF8.GetByteCount(result.RequestSummary!));
Assert.True(result.PayloadTruncated);
}
[Fact]
public void Multibyte_UTF8_TruncatedAtCharacterBoundary_NotMidByte()
{
// U+1F600 (grinning face) encodes to 4 UTF-8 bytes; 2000 of them = 8000 bytes,
// safely under the 8192 default cap so the boundary scan kicks in mid-character
// when we push past it. Pad with a few extra emoji so the *input* is > 8192 bytes
// and forces truncation.
var emoji = "😀"; // surrogate pair => one code point => 4 UTF-8 bytes
var sb = new StringBuilder();
for (int i = 0; i < 2100; i++)
{
sb.Append(emoji);
}
var input = sb.ToString();
Assert.True(Encoding.UTF8.GetByteCount(input) > 8192);
var evt = NewEvent(AuditStatus.Delivered, request: input);
var result = Filter().Apply(evt);
Assert.NotNull(result.RequestSummary);
var resultBytes = Encoding.UTF8.GetByteCount(result.RequestSummary!);
Assert.True(resultBytes <= 8192, $"expected <= 8192 bytes, got {resultBytes}");
// 4-byte emoji boundary: the kept byte length must be a multiple of 4.
Assert.Equal(0, resultBytes % 4);
// And round-tripping the result must not introduce a U+FFFD replacement char.
Assert.DoesNotContain('', result.RequestSummary);
Assert.True(result.PayloadTruncated);
}
[Fact]
public void NullSummary_PassesThrough_AsNull()
{
var evt = NewEvent(AuditStatus.Delivered, request: null, response: null, errorDetail: null, extra: null);
var result = Filter().Apply(evt);
Assert.Null(result.RequestSummary);
Assert.Null(result.ResponseSummary);
Assert.Null(result.ErrorDetail);
Assert.Null(result.Extra);
Assert.False(result.PayloadTruncated);
}
[Fact]
public void RawEventAlreadyTruncated_PayloadTruncatedRemainsTrue()
{
// Small payload that requires no truncation, but the caller already
// flagged PayloadTruncated upstream — the filter must not clear it.
var evt = NewEvent(AuditStatus.Delivered, request: "small", payloadTruncated: true);
var result = Filter().Apply(evt);
Assert.Equal("small", result.RequestSummary);
Assert.True(result.PayloadTruncated);
}
[Fact]
public void StatusAttempted_TreatedAsError_UsesErrorCap()
{
// 10 KB is under the 64 KB error cap; if Attempted were a success status
// the value would be truncated to 8 KB. We assert it is NOT truncated.
var input = new string('d', 10 * 1024);
var evt = NewEvent(AuditStatus.Attempted, request: input);
var result = Filter().Apply(evt);
Assert.Equal(input, result.RequestSummary);
Assert.False(result.PayloadTruncated);
}
[Fact]
public void StatusParked_TreatedAsError_UsesErrorCap()
{
var input = new string('e', 10 * 1024);
var evt = NewEvent(AuditStatus.Parked, request: input);
var result = Filter().Apply(evt);
Assert.Equal(input, result.RequestSummary);
Assert.False(result.PayloadTruncated);
}
[Fact]
public void StatusSkipped_TreatedAsError_UsesErrorCap()
{
var input = new string('f', 10 * 1024);
var evt = NewEvent(AuditStatus.Skipped, request: input);
var result = Filter().Apply(evt);
Assert.Equal(input, result.RequestSummary);
Assert.False(result.PayloadTruncated);
}
[Fact]
public void ErrorDetail_AndExtra_Truncated_Independently()
{
// Each field is capped on its own — a 10 KB RequestSummary and a 10 KB
// ErrorDetail on the same Delivered row should both be cut to 8 KB and
// the row flagged truncated.
var input = new string('g', 10 * 1024);
var evt = NewEvent(
AuditStatus.Delivered,
request: input,
response: input,
errorDetail: input,
extra: input);
var result = Filter().Apply(evt);
Assert.Equal(8192, Encoding.UTF8.GetByteCount(result.RequestSummary!));
Assert.Equal(8192, Encoding.UTF8.GetByteCount(result.ResponseSummary!));
Assert.Equal(8192, Encoding.UTF8.GetByteCount(result.ErrorDetail!));
Assert.Equal(8192, Encoding.UTF8.GetByteCount(result.Extra!));
Assert.True(result.PayloadTruncated);
}
/// <summary>
/// IOptionsMonitor test double — returns the same snapshot on every read,
/// no change-token plumbing required for these tests (Bundle D wires the
/// real hot-reload path).
/// </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;
}
}