feat(audit)!: ScadaBridge C3 — swap to canonical ZB.MOM.WW.Audit.AuditEvent across seams/emitters/DTO/redactor wiring; transitional 24-col storage shim (Task 2.5)
This commit is contained in:
+16
-16
@@ -4,6 +4,8 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||
@@ -55,16 +57,14 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
||||
var trackedId = trackedOperationId ?? TrackedOperationId.New();
|
||||
var now = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
var audit = new AuditEvent
|
||||
{
|
||||
EventId = eventId ?? Guid.NewGuid(),
|
||||
OccurredAtUtc = now,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.CachedSubmit,
|
||||
Status = auditStatus,
|
||||
SourceSiteId = siteId,
|
||||
CorrelationId = trackedId.Value,
|
||||
};
|
||||
var audit = ScadaBridgeAuditEventFactory.Create(
|
||||
eventId: eventId ?? Guid.NewGuid(),
|
||||
occurredAtUtc: now,
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
kind: AuditKind.CachedSubmit,
|
||||
status: auditStatus,
|
||||
sourceSiteId: siteId,
|
||||
correlationId: trackedId.Value);
|
||||
|
||||
var siteCall = new SiteCall
|
||||
{
|
||||
@@ -137,7 +137,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
||||
|
||||
// Verify rows landed in both tables.
|
||||
await using var read = CreateReadContext();
|
||||
var auditRow = await read.Set<AuditEvent>().SingleOrDefaultAsync(e => e.EventId == audit.EventId);
|
||||
var auditRow = await read.Set<AuditLogRow>().SingleOrDefaultAsync(e => e.EventId == audit.EventId);
|
||||
Assert.NotNull(auditRow);
|
||||
Assert.NotNull(auditRow!.IngestedAtUtc);
|
||||
|
||||
@@ -178,7 +178,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
||||
Assert.Equal(eventId, reply.AcceptedEventIds[0]);
|
||||
|
||||
await using var read = CreateReadContext();
|
||||
var auditCount = await read.Set<AuditEvent>().CountAsync(e => e.EventId == eventId);
|
||||
var auditCount = await read.Set<AuditLogRow>().CountAsync(e => e.EventId == eventId);
|
||||
Assert.Equal(1, auditCount);
|
||||
|
||||
var siteCallCount = await read.Set<SiteCall>()
|
||||
@@ -221,7 +221,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
||||
|
||||
// Both audit rows exist.
|
||||
await using var read = CreateReadContext();
|
||||
var auditRows = await read.Set<AuditEvent>()
|
||||
var auditRows = await read.Set<AuditLogRow>()
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.ToListAsync();
|
||||
Assert.Equal(2, auditRows.Count);
|
||||
@@ -256,7 +256,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
||||
Assert.Empty(reply.AcceptedEventIds);
|
||||
|
||||
await using var read = CreateReadContext();
|
||||
var auditRow = await read.Set<AuditEvent>().SingleOrDefaultAsync(e => e.EventId == audit.EventId);
|
||||
var auditRow = await read.Set<AuditLogRow>().SingleOrDefaultAsync(e => e.EventId == audit.EventId);
|
||||
Assert.Null(auditRow);
|
||||
|
||||
var siteCallRow = await read.Set<SiteCall>()
|
||||
@@ -287,7 +287,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
||||
.SetEquals(reply.AcceptedEventIds.ToHashSet()));
|
||||
|
||||
await using var read = CreateReadContext();
|
||||
var auditCount = await read.Set<AuditEvent>().CountAsync(e => e.SourceSiteId == siteId);
|
||||
var auditCount = await read.Set<AuditLogRow>().CountAsync(e => e.SourceSiteId == siteId);
|
||||
Assert.Equal(5, auditCount);
|
||||
|
||||
var siteCallCount = await read.Set<SiteCall>().CountAsync(s => s.SourceSite == siteId);
|
||||
@@ -329,7 +329,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
||||
Assert.DoesNotContain(audit2.EventId, reply.AcceptedEventIds);
|
||||
|
||||
await using var read = CreateReadContext();
|
||||
var auditRows = await read.Set<AuditEvent>().Where(e => e.SourceSiteId == siteId).ToListAsync();
|
||||
var auditRows = await read.Set<AuditLogRow>().Where(e => e.SourceSiteId == siteId).ToListAsync();
|
||||
Assert.Equal(2, auditRows.Count);
|
||||
Assert.DoesNotContain(auditRows, r => r.EventId == audit2.EventId);
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ using Akka.TestKit.Xunit2;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
@@ -41,15 +42,13 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
||||
private static string NewSiteId() =>
|
||||
"test-bundle-d2-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||
|
||||
private static AuditEvent NewEvent(string siteId, Guid? id = null) => new()
|
||||
{
|
||||
EventId = id ?? Guid.NewGuid(),
|
||||
OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
Status = AuditStatus.Delivered,
|
||||
SourceSiteId = siteId,
|
||||
};
|
||||
private static AuditEvent NewEvent(string siteId, Guid? id = null) => ScadaBridgeAuditEventFactory.Create(
|
||||
eventId: id ?? Guid.NewGuid(),
|
||||
occurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
kind: AuditKind.ApiCall,
|
||||
status: AuditStatus.Delivered,
|
||||
sourceSiteId: siteId);
|
||||
|
||||
private IActorRef CreateActor(IAuditLogRepository repository) =>
|
||||
Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
|
||||
@@ -76,7 +75,7 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
||||
|
||||
// Verify rows landed in MSSQL.
|
||||
await using var readContext = CreateContext();
|
||||
var rows = await readContext.Set<AuditEvent>()
|
||||
var rows = await readContext.Set<AuditLogRow>()
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.ToListAsync();
|
||||
Assert.Equal(5, rows.Count);
|
||||
@@ -115,7 +114,7 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
||||
|
||||
// Verify no double-insert.
|
||||
await using var readContext = CreateContext();
|
||||
var count = await readContext.Set<AuditEvent>()
|
||||
var count = await readContext.Set<AuditLogRow>()
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.CountAsync();
|
||||
Assert.Equal(3, count);
|
||||
@@ -141,7 +140,7 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
||||
var after = DateTime.UtcNow.AddSeconds(1);
|
||||
|
||||
await using var readContext = CreateContext();
|
||||
var rows = await readContext.Set<AuditEvent>()
|
||||
var rows = await readContext.Set<AuditLogRow>()
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.ToListAsync();
|
||||
|
||||
@@ -178,7 +177,7 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
||||
Assert.DoesNotContain(poisonId, reply.AcceptedEventIds);
|
||||
|
||||
await using var readContext = CreateContext();
|
||||
var rows = await readContext.Set<AuditEvent>()
|
||||
var rows = await readContext.Set<AuditLogRow>()
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.ToListAsync();
|
||||
Assert.Equal(4, rows.Count);
|
||||
|
||||
@@ -6,7 +6,8 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
@@ -272,24 +273,20 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
// * Jan partition (MAX = Jan 15) → older than threshold → PURGED
|
||||
// * Apr partition (MAX = Apr 15) → newer than threshold → KEPT
|
||||
var siteId = "purge-e2e-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||
var janEvt = new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc),
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
Status = AuditStatus.Delivered,
|
||||
SourceSiteId = siteId,
|
||||
};
|
||||
var aprEvt = new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc),
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
Status = AuditStatus.Delivered,
|
||||
SourceSiteId = siteId,
|
||||
};
|
||||
var janEvt = ScadaBridgeAuditEventFactory.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
occurredAtUtc: new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc),
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
kind: AuditKind.ApiCall,
|
||||
status: AuditStatus.Delivered,
|
||||
sourceSiteId: siteId);
|
||||
var aprEvt = ScadaBridgeAuditEventFactory.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
occurredAtUtc: new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc),
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
kind: AuditKind.ApiCall,
|
||||
status: AuditStatus.Delivered,
|
||||
sourceSiteId: siteId);
|
||||
|
||||
await using (var seedContext = CreateMsSqlContext())
|
||||
{
|
||||
@@ -341,7 +338,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
// Settle: allow any in-flight tick to commit before reading.
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(500));
|
||||
await using var verifyContext = CreateMsSqlContext();
|
||||
var rows = await verifyContext.Set<AuditEvent>()
|
||||
var rows = await verifyContext.Set<AuditLogRow>()
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.ToListAsync();
|
||||
|
||||
|
||||
+8
-10
@@ -3,7 +3,7 @@ using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
@@ -22,14 +22,12 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
||||
/// </summary>
|
||||
public class CentralAuditWriteFailuresTests : TestKit
|
||||
{
|
||||
private static AuditEvent NewEvent() => new()
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTime.UtcNow,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
Status = AuditStatus.Delivered,
|
||||
};
|
||||
private static AuditEvent NewEvent() => ScadaBridgeAuditEventFactory.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
occurredAtUtc: DateTime.UtcNow,
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
kind: AuditKind.ApiCall,
|
||||
status: AuditStatus.Delivered);
|
||||
|
||||
/// <summary>
|
||||
/// Repository stub that always throws on insert — exercises the failure
|
||||
@@ -84,7 +82,7 @@ public class CentralAuditWriteFailuresTests : TestKit
|
||||
var writer = new CentralAuditWriter(
|
||||
sp,
|
||||
NullLogger<CentralAuditWriter>.Instance,
|
||||
filter: null,
|
||||
redactor: null,
|
||||
failureCounter: counter);
|
||||
|
||||
// WriteAsync swallows the exception and increments the counter.
|
||||
|
||||
@@ -4,7 +4,8 @@ using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
@@ -22,16 +23,16 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
||||
/// </summary>
|
||||
public class CentralAuditWriterTests
|
||||
{
|
||||
private static AuditEvent NewEvent(Guid? eventId = null) => new()
|
||||
{
|
||||
EventId = eventId ?? Guid.NewGuid(),
|
||||
OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
||||
Channel = AuditChannel.Notification,
|
||||
Kind = AuditKind.NotifyDeliver,
|
||||
Status = AuditStatus.Attempted,
|
||||
CorrelationId = Guid.NewGuid(),
|
||||
Target = "ops-team",
|
||||
};
|
||||
// C3 (Task 2.5): canonical ZB.MOM.WW.Audit.AuditEvent via the shared factory.
|
||||
private static AuditEvent NewEvent(Guid? eventId = null) =>
|
||||
ScadaBridgeAuditEventFactory.Create(
|
||||
channel: AuditChannel.Notification,
|
||||
kind: AuditKind.NotifyDeliver,
|
||||
status: AuditStatus.Attempted,
|
||||
eventId: eventId ?? Guid.NewGuid(),
|
||||
occurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
||||
target: "ops-team",
|
||||
correlationId: Guid.NewGuid());
|
||||
|
||||
private static (CentralAuditWriter writer, IAuditLogRepository repo) BuildWriter()
|
||||
{
|
||||
@@ -65,10 +66,12 @@ public class CentralAuditWriterTests
|
||||
|
||||
var after = DateTime.UtcNow;
|
||||
await repo.Received(1).InsertIfNotExistsAsync(
|
||||
// C3 (Task 2.5): IngestedAtUtc now rides in DetailsJson on the canonical
|
||||
// record — read it back via the decomposed row view.
|
||||
Arg.Is<AuditEvent>(e =>
|
||||
e.IngestedAtUtc != null &&
|
||||
e.IngestedAtUtc >= before &&
|
||||
e.IngestedAtUtc <= after),
|
||||
e.AsRow().IngestedAtUtc != null &&
|
||||
e.AsRow().IngestedAtUtc >= before &&
|
||||
e.AsRow().IngestedAtUtc <= after),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
@@ -138,7 +141,7 @@ public class CentralAuditWriterTests
|
||||
var writer = new CentralAuditWriter(
|
||||
provider,
|
||||
NullLogger<CentralAuditWriter>.Instance,
|
||||
filter: null,
|
||||
redactor: null,
|
||||
failureCounter: null,
|
||||
nodeIdentity: nodeIdentity);
|
||||
return (writer, repo);
|
||||
|
||||
+10
-11
@@ -5,7 +5,8 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
@@ -38,15 +39,13 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
private static AuditEvent NewEvent(
|
||||
string siteId,
|
||||
DateTime? occurredAt = null,
|
||||
Guid? id = null) => new()
|
||||
{
|
||||
EventId = id ?? Guid.NewGuid(),
|
||||
OccurredAtUtc = occurredAt ?? new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
Status = AuditStatus.Delivered,
|
||||
SourceSiteId = siteId,
|
||||
};
|
||||
Guid? id = null) => ScadaBridgeAuditEventFactory.Create(
|
||||
eventId: id ?? Guid.NewGuid(),
|
||||
occurredAtUtc: occurredAt ?? new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
kind: AuditKind.ApiCall,
|
||||
status: AuditStatus.Delivered,
|
||||
sourceSiteId: siteId);
|
||||
|
||||
private static SiteAuditReconciliationOptions FastTickOptions(
|
||||
int batchSize = 256,
|
||||
@@ -312,7 +311,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
// exist in MSSQL alongside the pre-existing one — InsertIfNotExistsAsync
|
||||
// is first-write-wins on EventId.
|
||||
await using var read = CreateContext();
|
||||
var rows = await read.Set<AuditEvent>()
|
||||
var rows = await read.Set<AuditLogRow>()
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.ToListAsync();
|
||||
Assert.Equal(2, rows.Count);
|
||||
|
||||
+28
-32
@@ -4,8 +4,8 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
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.AuditLog.Redaction;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration;
|
||||
@@ -95,25 +95,23 @@ public class AuditLogOptionsBindingTests
|
||||
// PayloadTruncated flips to true.
|
||||
var initial = new AuditLogOptions { DefaultCapBytes = 4096 };
|
||||
var monitor = new TestOptionsMonitor<AuditLogOptions>(initial);
|
||||
var filter = new DefaultAuditPayloadFilter(
|
||||
var filter = new ScadaBridgeAuditRedactor(
|
||||
monitor,
|
||||
NullLogger<DefaultAuditPayloadFilter>.Instance);
|
||||
NullLogger<ScadaBridgeAuditRedactor>.Instance);
|
||||
|
||||
var body = new string('x', 5 * 1024);
|
||||
var evt = new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTime.UtcNow,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
Status = AuditStatus.Delivered,
|
||||
RequestSummary = body,
|
||||
};
|
||||
var evt = ScadaBridgeAuditEventFactory.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
occurredAtUtc: DateTime.UtcNow,
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
kind: AuditKind.ApiCall,
|
||||
status: AuditStatus.Delivered,
|
||||
requestSummary: body);
|
||||
|
||||
var resultBefore = filter.Apply(evt);
|
||||
Assert.True(resultBefore.PayloadTruncated, "5KB body at 4096 cap must be truncated");
|
||||
Assert.NotNull(resultBefore.RequestSummary);
|
||||
Assert.True(Encoding.UTF8.GetByteCount(resultBefore.RequestSummary!) <= 4096);
|
||||
Assert.True(resultBefore.AsRow().PayloadTruncated, "5KB body at 4096 cap must be truncated");
|
||||
Assert.NotNull(resultBefore.AsRow().RequestSummary);
|
||||
Assert.True(Encoding.UTF8.GetByteCount(resultBefore.AsRow().RequestSummary!) <= 4096);
|
||||
|
||||
// Reload: cap raised to 16384 — next event must NOT truncate. This is
|
||||
// the M5-T8 contract: the filter sees the new value on the very next
|
||||
@@ -121,8 +119,8 @@ public class AuditLogOptionsBindingTests
|
||||
monitor.Set(new AuditLogOptions { DefaultCapBytes = 16384 });
|
||||
|
||||
var resultAfter = filter.Apply(evt);
|
||||
Assert.False(resultAfter.PayloadTruncated, "5KB body at 16384 cap must NOT be truncated");
|
||||
Assert.Equal(body, resultAfter.RequestSummary);
|
||||
Assert.False(resultAfter.AsRow().PayloadTruncated, "5KB body at 16384 cap must NOT be truncated");
|
||||
Assert.Equal(body, resultAfter.AsRow().RequestSummary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -133,23 +131,21 @@ public class AuditLogOptionsBindingTests
|
||||
// process restart. Pre-reload: no redactor, hunter2 survives. After
|
||||
// reload: hunter2 redacted.
|
||||
var monitor = new TestOptionsMonitor<AuditLogOptions>(new AuditLogOptions());
|
||||
var filter = new DefaultAuditPayloadFilter(
|
||||
var filter = new ScadaBridgeAuditRedactor(
|
||||
monitor,
|
||||
NullLogger<DefaultAuditPayloadFilter>.Instance);
|
||||
NullLogger<ScadaBridgeAuditRedactor>.Instance);
|
||||
|
||||
const string body = "{\"user\":\"alice\",\"password\":\"hunter2\"}";
|
||||
var evt = new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTime.UtcNow,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
Status = AuditStatus.Delivered,
|
||||
RequestSummary = body,
|
||||
};
|
||||
var evt = ScadaBridgeAuditEventFactory.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
occurredAtUtc: DateTime.UtcNow,
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
kind: AuditKind.ApiCall,
|
||||
status: AuditStatus.Delivered,
|
||||
requestSummary: body);
|
||||
|
||||
var before = filter.Apply(evt);
|
||||
Assert.Contains("hunter2", before.RequestSummary!);
|
||||
Assert.Contains("hunter2", before.AsRow().RequestSummary!);
|
||||
|
||||
monitor.Set(new AuditLogOptions
|
||||
{
|
||||
@@ -157,8 +153,8 @@ public class AuditLogOptionsBindingTests
|
||||
});
|
||||
|
||||
var after = filter.Apply(evt);
|
||||
Assert.DoesNotContain("hunter2", after.RequestSummary!);
|
||||
Assert.Contains("<redacted>", after.RequestSummary!);
|
||||
Assert.DoesNotContain("hunter2", after.AsRow().RequestSummary!);
|
||||
Assert.Contains("<redacted>", after.AsRow().RequestSummary!);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+3
-1
@@ -11,7 +11,9 @@ using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
|
||||
+17
-17
@@ -1,7 +1,10 @@
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
@@ -53,20 +56,17 @@ public class CachedCallCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMigr
|
||||
private static CachedCallTelemetry SubmitPacket(
|
||||
TrackedOperationId id, string siteId, DateTime nowUtc, string target = "ERP.GetOrder") =>
|
||||
new(
|
||||
Audit: new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = nowUtc,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.CachedSubmit,
|
||||
CorrelationId = id.Value,
|
||||
SourceSiteId = siteId,
|
||||
SourceInstanceId = "Plant.Pump42",
|
||||
SourceScript = "ScriptActor:doStuff",
|
||||
Target = target,
|
||||
Status = AuditStatus.Submitted,
|
||||
ForwardState = AuditForwardState.Pending,
|
||||
},
|
||||
Audit: ScadaBridgeAuditEventFactory.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
occurredAtUtc: nowUtc,
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
kind: AuditKind.CachedSubmit,
|
||||
correlationId: id.Value,
|
||||
sourceSiteId: siteId,
|
||||
sourceInstanceId: "Plant.Pump42",
|
||||
sourceScript: "ScriptActor:doStuff",
|
||||
target: target,
|
||||
status: AuditStatus.Submitted),
|
||||
Operational: new SiteCallOperational(
|
||||
TrackedOperationId: id,
|
||||
Channel: "ApiOutbound",
|
||||
@@ -149,7 +149,7 @@ public class CachedCallCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMigr
|
||||
// 1 Submit + 2 transient Attempted + 1 terminal Attempted + 1
|
||||
// CachedResolve = 5 audit rows. The plan allows 4-5; this is the
|
||||
// happy path emitting exactly 5.
|
||||
var auditRows = await read.Set<AuditEvent>()
|
||||
var auditRows = await read.Set<AuditLogRow>()
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.ToListAsync();
|
||||
Assert.InRange(auditRows.Count, 4, 5);
|
||||
@@ -215,7 +215,7 @@ public class CachedCallCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMigr
|
||||
Assert.NotNull(siteCall.TerminalAtUtc);
|
||||
|
||||
// Terminal audit row should also be Parked.
|
||||
var resolve = await read.Set<AuditEvent>()
|
||||
var resolve = await read.Set<AuditLogRow>()
|
||||
.Where(e => e.SourceSiteId == siteId && e.Kind == AuditKind.CachedResolve)
|
||||
.SingleAsync();
|
||||
Assert.Equal(AuditStatus.Parked, resolve.Status);
|
||||
@@ -255,7 +255,7 @@ public class CachedCallCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMigr
|
||||
Assert.NotNull(siteCall.TerminalAtUtc);
|
||||
|
||||
// 1 Submit + 1 Attempted + 1 CachedResolve = 3 audit rows.
|
||||
var auditRows = await read.Set<AuditEvent>()
|
||||
var auditRows = await read.Set<AuditLogRow>()
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.ToListAsync();
|
||||
Assert.Equal(3, auditRows.Count);
|
||||
|
||||
+16
-16
@@ -1,7 +1,10 @@
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
@@ -42,20 +45,17 @@ public class CachedWriteCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMig
|
||||
private static CachedCallTelemetry DbSubmitPacket(
|
||||
TrackedOperationId id, string siteId, DateTime nowUtc, string target = "OperationsDb.UpdateOrder") =>
|
||||
new(
|
||||
Audit: new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = nowUtc,
|
||||
Channel = AuditChannel.DbOutbound,
|
||||
Kind = AuditKind.CachedSubmit,
|
||||
CorrelationId = id.Value,
|
||||
SourceSiteId = siteId,
|
||||
SourceInstanceId = "Plant.Pump42",
|
||||
SourceScript = "ScriptActor:doStuff",
|
||||
Target = target,
|
||||
Status = AuditStatus.Submitted,
|
||||
ForwardState = AuditForwardState.Pending,
|
||||
},
|
||||
Audit: ScadaBridgeAuditEventFactory.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
occurredAtUtc: nowUtc,
|
||||
channel: AuditChannel.DbOutbound,
|
||||
kind: AuditKind.CachedSubmit,
|
||||
correlationId: id.Value,
|
||||
sourceSiteId: siteId,
|
||||
sourceInstanceId: "Plant.Pump42",
|
||||
sourceScript: "ScriptActor:doStuff",
|
||||
target: target,
|
||||
status: AuditStatus.Submitted),
|
||||
Operational: new SiteCallOperational(
|
||||
TrackedOperationId: id,
|
||||
Channel: "DbOutbound",
|
||||
@@ -122,7 +122,7 @@ public class CachedWriteCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMig
|
||||
Assert.Equal(0, siteCall.RetryCount);
|
||||
Assert.NotNull(siteCall.TerminalAtUtc);
|
||||
|
||||
var auditRows = await read.Set<AuditEvent>()
|
||||
var auditRows = await read.Set<AuditLogRow>()
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.ToListAsync();
|
||||
Assert.Equal(3, auditRows.Count);
|
||||
@@ -182,7 +182,7 @@ public class CachedWriteCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMig
|
||||
Assert.Equal("Parked", siteCall.Status);
|
||||
Assert.NotNull(siteCall.TerminalAtUtc);
|
||||
|
||||
var resolve = await read.Set<AuditEvent>()
|
||||
var resolve = await read.Set<AuditLogRow>()
|
||||
.Where(e => e.SourceSiteId == siteId && e.Kind == AuditKind.CachedResolve)
|
||||
.SingleAsync();
|
||||
Assert.Equal(AuditStatus.Parked, resolve.Status);
|
||||
|
||||
+16
-16
@@ -1,7 +1,10 @@
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
@@ -54,20 +57,17 @@ public class CombinedTelemetryIdempotencyTests : TestKit, IClassFixture<MsSqlMig
|
||||
{
|
||||
var dto = new CachedTelemetryPacket
|
||||
{
|
||||
AuditEvent = AuditEventDtoMapper.ToDto(new AuditEvent
|
||||
{
|
||||
EventId = eventId,
|
||||
OccurredAtUtc = nowUtc,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = kind,
|
||||
CorrelationId = trackedId.Value,
|
||||
SourceSiteId = siteId,
|
||||
Target = "ERP.GetOrder",
|
||||
Status = auditStatus,
|
||||
HttpStatus = httpStatus,
|
||||
ErrorMessage = lastError,
|
||||
ForwardState = AuditForwardState.Pending,
|
||||
}),
|
||||
AuditEvent = AuditEventDtoMapper.ToDto(ScadaBridgeAuditEventFactory.Create(
|
||||
eventId: eventId,
|
||||
occurredAtUtc: nowUtc,
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
kind: kind,
|
||||
correlationId: trackedId.Value,
|
||||
sourceSiteId: siteId,
|
||||
target: "ERP.GetOrder",
|
||||
status: auditStatus,
|
||||
httpStatus: httpStatus,
|
||||
errorMessage: lastError)),
|
||||
Operational = new SiteCallOperationalDto
|
||||
{
|
||||
TrackedOperationId = trackedId.Value.ToString("D"),
|
||||
@@ -131,7 +131,7 @@ public class CombinedTelemetryIdempotencyTests : TestKit, IClassFixture<MsSqlMig
|
||||
await using var read = harness.CreateReadContext();
|
||||
|
||||
// AuditLog: exactly ONE row for the EventId (insert-if-not-exists).
|
||||
var auditCount = await read.Set<AuditEvent>()
|
||||
var auditCount = await read.Set<AuditLogRow>()
|
||||
.CountAsync(e => e.EventId == eventId);
|
||||
Assert.Equal(1, auditCount);
|
||||
|
||||
@@ -183,7 +183,7 @@ public class CombinedTelemetryIdempotencyTests : TestKit, IClassFixture<MsSqlMig
|
||||
// AuditLog: TWO rows now exist for this lifecycle — the Submit and
|
||||
// the Attempted. Their order is by OccurredAtUtc; the test doesn't
|
||||
// assert ordering, only count + correlation.
|
||||
var auditRows = await read.Set<AuditEvent>()
|
||||
var auditRows = await read.Set<AuditLogRow>()
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.ToListAsync();
|
||||
Assert.Equal(2, auditRows.Count);
|
||||
|
||||
+17
-17
@@ -220,17 +220,17 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMig
|
||||
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
|
||||
new AuditLogPaging(PageSize: 10));
|
||||
var evt = Assert.Single(rows);
|
||||
Assert.Equal(AuditChannel.DbOutbound, evt.Channel);
|
||||
Assert.Equal(AuditKind.DbWrite, evt.Kind);
|
||||
Assert.Equal(AuditStatus.Delivered, evt.Status);
|
||||
Assert.Equal(siteId, evt.SourceSiteId);
|
||||
Assert.Equal(InstanceName, evt.SourceInstanceId);
|
||||
Assert.Equal(SourceScript, evt.SourceScript);
|
||||
Assert.NotNull(evt.Extra);
|
||||
Assert.Contains("\"op\":\"write\"", evt.Extra);
|
||||
Assert.Contains("\"rowsAffected\":1", evt.Extra);
|
||||
Assert.Equal(AuditChannel.DbOutbound, evt.AsRow().Channel);
|
||||
Assert.Equal(AuditKind.DbWrite, evt.AsRow().Kind);
|
||||
Assert.Equal(AuditStatus.Delivered, evt.AsRow().Status);
|
||||
Assert.Equal(siteId, evt.AsRow().SourceSiteId);
|
||||
Assert.Equal(InstanceName, evt.AsRow().SourceInstanceId);
|
||||
Assert.Equal(SourceScript, evt.AsRow().SourceScript);
|
||||
Assert.NotNull(evt.AsRow().Extra);
|
||||
Assert.Contains("\"op\":\"write\"", evt.AsRow().Extra);
|
||||
Assert.Contains("\"rowsAffected\":1", evt.AsRow().Extra);
|
||||
// Central stamps IngestedAtUtc; the site never sets it.
|
||||
Assert.NotNull(evt.IngestedAtUtc);
|
||||
Assert.NotNull(evt.AsRow().IngestedAtUtc);
|
||||
Assert.StartsWith(ConnectionName, evt.Target);
|
||||
}, TimeSpan.FromSeconds(15));
|
||||
}
|
||||
@@ -288,13 +288,13 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMig
|
||||
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
|
||||
new AuditLogPaging(PageSize: 10));
|
||||
var evt = Assert.Single(rows);
|
||||
Assert.Equal(AuditChannel.DbOutbound, evt.Channel);
|
||||
Assert.Equal(AuditKind.DbWrite, evt.Kind);
|
||||
Assert.Equal(AuditStatus.Delivered, evt.Status);
|
||||
Assert.NotNull(evt.Extra);
|
||||
Assert.Contains("\"op\":\"read\"", evt.Extra);
|
||||
Assert.Contains("\"rowsReturned\":2", evt.Extra);
|
||||
Assert.NotNull(evt.IngestedAtUtc);
|
||||
Assert.Equal(AuditChannel.DbOutbound, evt.AsRow().Channel);
|
||||
Assert.Equal(AuditKind.DbWrite, evt.AsRow().Kind);
|
||||
Assert.Equal(AuditStatus.Delivered, evt.AsRow().Status);
|
||||
Assert.NotNull(evt.AsRow().Extra);
|
||||
Assert.Contains("\"op\":\"read\"", evt.AsRow().Extra);
|
||||
Assert.Contains("\"rowsReturned\":2", evt.AsRow().Extra);
|
||||
Assert.NotNull(evt.AsRow().IngestedAtUtc);
|
||||
}, TimeSpan.FromSeconds(15));
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -219,18 +219,18 @@ public class ExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMigration
|
||||
// core promise of the per-run correlation value.
|
||||
Assert.All(rows, r =>
|
||||
{
|
||||
Assert.NotNull(r.ExecutionId);
|
||||
Assert.Equal(executionId, r.ExecutionId);
|
||||
Assert.Equal(siteId, r.SourceSiteId);
|
||||
Assert.NotNull(r.AsRow().ExecutionId);
|
||||
Assert.Equal(executionId, r.AsRow().ExecutionId);
|
||||
Assert.Equal(siteId, r.AsRow().SourceSiteId);
|
||||
// Central stamps IngestedAtUtc; the site never sets it.
|
||||
Assert.NotNull(r.IngestedAtUtc);
|
||||
Assert.NotNull(r.AsRow().IngestedAtUtc);
|
||||
});
|
||||
|
||||
// The two rows are the two distinct trust-boundary actions — one
|
||||
// outbound API call and one outbound DB write — proving the shared
|
||||
// id spans different channels, not two rows of the same action.
|
||||
Assert.Single(rows, r => r.Channel == AuditChannel.ApiOutbound && r.Kind == AuditKind.ApiCall);
|
||||
Assert.Single(rows, r => r.Channel == AuditChannel.DbOutbound && r.Kind == AuditKind.DbWrite);
|
||||
Assert.Single(rows, r => r.AsRow().Channel == AuditChannel.ApiOutbound && r.AsRow().Kind == AuditKind.ApiCall);
|
||||
Assert.Single(rows, r => r.AsRow().Channel == AuditChannel.DbOutbound && r.AsRow().Kind == AuditKind.DbWrite);
|
||||
}, TimeSpan.FromSeconds(15));
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
@@ -223,15 +223,13 @@ public class InboundApiAuditTests : IClassFixture<MsSqlMigrationFixture>
|
||||
Assert.Equal(System.Net.HttpStatusCode.OK, resp.StatusCode);
|
||||
|
||||
var evt = await AwaitOneAsync(methodName);
|
||||
Assert.Equal(AuditChannel.ApiInbound, evt.Channel);
|
||||
Assert.Equal(AuditKind.InboundRequest, evt.Kind);
|
||||
Assert.Equal(AuditStatus.Delivered, evt.Status);
|
||||
Assert.Equal(200, evt.HttpStatus);
|
||||
Assert.Equal(AuditChannel.ApiInbound, evt.AsRow().Channel);
|
||||
Assert.Equal(AuditKind.InboundRequest, evt.AsRow().Kind);
|
||||
Assert.Equal(AuditStatus.Delivered, evt.AsRow().Status);
|
||||
Assert.Equal(200, evt.AsRow().HttpStatus);
|
||||
Assert.Equal("integration-svc", evt.Actor);
|
||||
// Central direct-write — no site-local forward state (alog.md §6).
|
||||
Assert.Null(evt.ForwardState);
|
||||
// IngestedAtUtc stamped by the central writer.
|
||||
Assert.NotNull(evt.IngestedAtUtc);
|
||||
Assert.NotNull(evt.AsRow().IngestedAtUtc);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
@@ -257,13 +255,15 @@ public class InboundApiAuditTests : IClassFixture<MsSqlMigrationFixture>
|
||||
Assert.Equal(System.Net.HttpStatusCode.Unauthorized, resp.StatusCode);
|
||||
|
||||
var evt = await AwaitOneAsync(methodName);
|
||||
Assert.Equal(AuditChannel.ApiInbound, evt.Channel);
|
||||
Assert.Equal(AuditKind.InboundAuthFailure, evt.Kind);
|
||||
Assert.Equal(AuditStatus.Failed, evt.Status);
|
||||
Assert.Equal(401, evt.HttpStatus);
|
||||
Assert.Equal(AuditChannel.ApiInbound, evt.AsRow().Channel);
|
||||
Assert.Equal(AuditKind.InboundAuthFailure, evt.AsRow().Kind);
|
||||
Assert.Equal(AuditStatus.Failed, evt.AsRow().Status);
|
||||
Assert.Equal(401, evt.AsRow().HttpStatus);
|
||||
// Never echo back an unauthenticated principal — middleware suppresses
|
||||
// the framework user resolution on 401/403 paths.
|
||||
Assert.Null(evt.Actor);
|
||||
// the framework user resolution on 401/403 paths. C3 (Task 2.5): the
|
||||
// canonical Actor is a non-null string (empty when absent); the row view
|
||||
// maps empty → null, preserving the "no principal" assertion.
|
||||
Assert.Null(evt.AsRow().Actor);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
@@ -290,10 +290,10 @@ public class InboundApiAuditTests : IClassFixture<MsSqlMigrationFixture>
|
||||
Assert.Equal(System.Net.HttpStatusCode.InternalServerError, resp.StatusCode);
|
||||
|
||||
var evt = await AwaitOneAsync(methodName);
|
||||
Assert.Equal(AuditChannel.ApiInbound, evt.Channel);
|
||||
Assert.Equal(AuditKind.InboundRequest, evt.Kind);
|
||||
Assert.Equal(AuditStatus.Failed, evt.Status);
|
||||
Assert.Equal(500, evt.HttpStatus);
|
||||
Assert.Equal(AuditChannel.ApiInbound, evt.AsRow().Channel);
|
||||
Assert.Equal(AuditKind.InboundRequest, evt.AsRow().Kind);
|
||||
Assert.Equal(AuditStatus.Failed, evt.AsRow().Status);
|
||||
Assert.Equal(500, evt.AsRow().HttpStatus);
|
||||
Assert.Equal("integration-svc", evt.Actor);
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -1,6 +1,8 @@
|
||||
using Akka.Actor;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
|
||||
+19
-22
@@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
@@ -185,21 +185,18 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture<MsSqlMigra
|
||||
{
|
||||
await using var ctx = CreateContext();
|
||||
var repo = new AuditLogRepository(ctx);
|
||||
var submitEvt = new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTime.UtcNow.AddMinutes(-1),
|
||||
Channel = AuditChannel.Notification,
|
||||
Kind = AuditKind.NotifySend,
|
||||
CorrelationId = notificationId,
|
||||
SourceSiteId = siteId,
|
||||
SourceInstanceId = "Plant.Pump42",
|
||||
SourceScript = "AlarmScript",
|
||||
Target = "ops-team",
|
||||
Status = AuditStatus.Submitted,
|
||||
ForwardState = AuditForwardState.Forwarded,
|
||||
IngestedAtUtc = DateTime.UtcNow.AddMinutes(-1),
|
||||
};
|
||||
var submitEvt = ScadaBridgeAuditEventFactory.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
occurredAtUtc: DateTime.UtcNow.AddMinutes(-1),
|
||||
channel: AuditChannel.Notification,
|
||||
kind: AuditKind.NotifySend,
|
||||
correlationId: notificationId,
|
||||
sourceSiteId: siteId,
|
||||
sourceInstanceId: "Plant.Pump42",
|
||||
sourceScript: "AlarmScript",
|
||||
target: "ops-team",
|
||||
status: AuditStatus.Submitted,
|
||||
ingestedAtUtc: new DateTimeOffset(DateTime.UtcNow.AddMinutes(-1)));
|
||||
await repo.InsertIfNotExistsAsync(submitEvt);
|
||||
}
|
||||
|
||||
@@ -248,9 +245,9 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture<MsSqlMigra
|
||||
new AuditLogPaging(PageSize: 50));
|
||||
// 1 Submit + 1 Attempted = 2 rows so far.
|
||||
Assert.Equal(2, rows.Count);
|
||||
Assert.Single(rows, r => r.Kind == AuditKind.NotifyDeliver
|
||||
&& r.Status == AuditStatus.Attempted);
|
||||
Assert.Single(rows, r => r.Kind == AuditKind.NotifySend);
|
||||
Assert.Single(rows, r => r.AsRow().Kind == AuditKind.NotifyDeliver
|
||||
&& r.AsRow().Status == AuditStatus.Attempted);
|
||||
Assert.Single(rows, r => r.AsRow().Kind == AuditKind.NotifySend);
|
||||
}, TimeSpan.FromSeconds(15));
|
||||
|
||||
// Second tick: success → second Attempted + one Delivered terminal.
|
||||
@@ -265,10 +262,10 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture<MsSqlMigra
|
||||
// 1 Submit + 2 Attempted + 1 Delivered terminal = 4 rows.
|
||||
Assert.InRange(rows.Count, 3, 4);
|
||||
var notifyDeliverRows = rows
|
||||
.Where(r => r.Kind == AuditKind.NotifyDeliver)
|
||||
.Where(r => r.AsRow().Kind == AuditKind.NotifyDeliver)
|
||||
.ToList();
|
||||
Assert.Equal(2, notifyDeliverRows.Count(r => r.Status == AuditStatus.Attempted));
|
||||
var terminal = Assert.Single(notifyDeliverRows, r => r.Status == AuditStatus.Delivered);
|
||||
Assert.Equal(2, notifyDeliverRows.Count(r => r.AsRow().Status == AuditStatus.Attempted));
|
||||
var terminal = Assert.Single(notifyDeliverRows, r => r.AsRow().Status == AuditStatus.Delivered);
|
||||
// All NotifyDeliver rows correlate to the original notification id.
|
||||
Assert.All(notifyDeliverRows, r => Assert.Equal(notificationId, r.CorrelationId));
|
||||
Assert.Equal("ops-team", terminal.Target);
|
||||
|
||||
+15
-15
@@ -7,7 +7,9 @@ using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||
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.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||
@@ -129,16 +131,14 @@ public class OutageReconciliationTests : TestKit, IClassFixture<MsSqlMigrationFi
|
||||
new(new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||
.UseSqlServer(_fixture.ConnectionString).Options);
|
||||
|
||||
private static AuditEvent NewEvent(string siteId, DateTime occurredAt) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = occurredAt,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
Status = AuditStatus.Delivered,
|
||||
SourceSiteId = siteId,
|
||||
Target = "external-system-a/method",
|
||||
};
|
||||
private static AuditEvent NewEvent(string siteId, DateTime occurredAt) => ScadaBridgeAuditEventFactory.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
occurredAtUtc: occurredAt,
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
kind: AuditKind.ApiCall,
|
||||
status: AuditStatus.Delivered,
|
||||
sourceSiteId: siteId,
|
||||
target: "external-system-a/method");
|
||||
|
||||
private SqliteAuditWriter CreateInMemorySqliteWriter() =>
|
||||
new SqliteAuditWriter(
|
||||
@@ -243,7 +243,7 @@ public class OutageReconciliationTests : TestKit, IClassFixture<MsSqlMigrationFi
|
||||
await AwaitAssertAsync(async () =>
|
||||
{
|
||||
await using var ctx = CreateContext();
|
||||
var count = await ctx.Set<AuditEvent>()
|
||||
var count = await ctx.Set<AuditLogRow>()
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.CountAsync();
|
||||
Assert.Equal(totalEvents, count);
|
||||
@@ -265,7 +265,7 @@ public class OutageReconciliationTests : TestKit, IClassFixture<MsSqlMigrationFi
|
||||
// Step 5: assert no duplicates by EventId — central must have
|
||||
// exactly the 200 rows we wrote at the site (one row per EventId).
|
||||
await using var verify = CreateContext();
|
||||
var centralIds = await verify.Set<AuditEvent>()
|
||||
var centralIds = await verify.Set<AuditLogRow>()
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.Select(e => e.EventId)
|
||||
.ToListAsync();
|
||||
@@ -317,7 +317,7 @@ public class OutageReconciliationTests : TestKit, IClassFixture<MsSqlMigrationFi
|
||||
await AwaitAssertAsync(async () =>
|
||||
{
|
||||
await using var ctx = CreateContext();
|
||||
var count = await ctx.Set<AuditEvent>()
|
||||
var count = await ctx.Set<AuditLogRow>()
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.CountAsync();
|
||||
Assert.Equal(totalEvents, count);
|
||||
@@ -339,7 +339,7 @@ public class OutageReconciliationTests : TestKit, IClassFixture<MsSqlMigrationFi
|
||||
// even though the cursor + read-Reconciled-too semantics could
|
||||
// theoretically re-fetch on the second cycle.
|
||||
await using var verify = CreateContext();
|
||||
var rows = await verify.Set<AuditEvent>()
|
||||
var rows = await verify.Set<AuditLogRow>()
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.ToListAsync();
|
||||
Assert.Equal(totalEvents, rows.Count);
|
||||
|
||||
+15
-14
@@ -17,7 +17,8 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
|
||||
@@ -315,23 +316,23 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMig
|
||||
// + NotifyDeliver Attempted/Delivered (2) = 7 rows for the routed run.
|
||||
Assert.True(siteRows.Count == 7,
|
||||
"Expected 7 routed-run audit rows; saw: "
|
||||
+ string.Join(", ", siteRows.Select(r => $"{r.Channel}/{r.Kind}/{r.Status}")));
|
||||
Assert.Single(siteRows, r => r.Channel == AuditChannel.ApiOutbound && r.Kind == AuditKind.ApiCall);
|
||||
Assert.Single(siteRows, r => r.Kind == AuditKind.CachedSubmit);
|
||||
Assert.Single(siteRows, r => r.Kind == AuditKind.CachedResolve);
|
||||
Assert.Single(siteRows, r => r.Kind == AuditKind.NotifySend);
|
||||
Assert.Equal(2, siteRows.Count(r => r.Kind == AuditKind.NotifyDeliver));
|
||||
+ string.Join(", ", siteRows.Select(r => $"{r.AsRow().Channel}/{r.AsRow().Kind}/{r.AsRow().Status}")));
|
||||
Assert.Single(siteRows, r => r.AsRow().Channel == AuditChannel.ApiOutbound && r.AsRow().Kind == AuditKind.ApiCall);
|
||||
Assert.Single(siteRows, r => r.AsRow().Kind == AuditKind.CachedSubmit);
|
||||
Assert.Single(siteRows, r => r.AsRow().Kind == AuditKind.CachedResolve);
|
||||
Assert.Single(siteRows, r => r.AsRow().Kind == AuditKind.NotifySend);
|
||||
Assert.Equal(2, siteRows.Count(r => r.AsRow().Kind == AuditKind.NotifyDeliver));
|
||||
|
||||
// CORE PROMISE: every routed-run row carries the SAME non-null
|
||||
// ParentExecutionId — the inbound request's ExecutionId.
|
||||
var parentIds = siteRows.Select(r => r.ParentExecutionId).Distinct().ToList();
|
||||
var parentIds = siteRows.Select(r => r.AsRow().ParentExecutionId).Distinct().ToList();
|
||||
Assert.Single(parentIds);
|
||||
Assert.NotNull(parentIds[0]);
|
||||
var inboundExecutionId = parentIds[0]!.Value;
|
||||
|
||||
// The routed run has its OWN distinct ExecutionId — not the parent's.
|
||||
var routedExecutionIds = siteRows
|
||||
.Select(r => r.ExecutionId)
|
||||
.Select(r => r.AsRow().ExecutionId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
Assert.Single(routedExecutionIds);
|
||||
@@ -345,9 +346,9 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMig
|
||||
new AuditLogQueryFilter(ExecutionId: inboundExecutionId),
|
||||
new AuditLogPaging(PageSize: 10));
|
||||
var inboundRow = Assert.Single(inboundRows,
|
||||
r => r.Channel == AuditChannel.ApiInbound && r.Kind == AuditKind.InboundRequest);
|
||||
Assert.Equal(AuditStatus.Delivered, inboundRow.Status);
|
||||
Assert.Null(inboundRow.ParentExecutionId);
|
||||
r => r.AsRow().Channel == AuditChannel.ApiInbound && r.AsRow().Kind == AuditKind.InboundRequest);
|
||||
Assert.Equal(AuditStatus.Delivered, inboundRow.AsRow().Status);
|
||||
Assert.Null(inboundRow.AsRow().ParentExecutionId);
|
||||
|
||||
// The parentExecutionId filter pulls the routed run's complete
|
||||
// trust-boundary footprint (all 7 routed rows, none of the inbound).
|
||||
@@ -355,7 +356,7 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMig
|
||||
new AuditLogQueryFilter(ParentExecutionId: inboundExecutionId),
|
||||
new AuditLogPaging(PageSize: 100));
|
||||
Assert.Equal(7, byParent.Count);
|
||||
Assert.All(byParent, r => Assert.Equal(routedExecutionId, r.ExecutionId));
|
||||
Assert.All(byParent, r => Assert.Equal(routedExecutionId, r.AsRow().ExecutionId));
|
||||
|
||||
// GetExecutionTreeAsync returns BOTH executions in one chain —
|
||||
// inbound (root) and routed (child), regardless of entry point.
|
||||
@@ -502,7 +503,7 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMig
|
||||
var pendingCached = await sqliteWriter.ReadPendingCachedTelemetryAsync(256);
|
||||
var forwarded = await sqliteWriter.ReadForwardedAsync(256);
|
||||
var kinds = pending.Concat(pendingCached).Concat(forwarded)
|
||||
.Select(r => r.Kind).ToHashSet();
|
||||
.Select(r => r.AsRow().Kind).ToHashSet();
|
||||
var missing = expectedKinds.Where(k => !kinds.Contains(k)).ToList();
|
||||
Assert.True(
|
||||
missing.Count == 0,
|
||||
|
||||
@@ -7,7 +7,9 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
@@ -201,7 +203,7 @@ WHERE name = 'UX_AuditLog_EventId'
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(500));
|
||||
|
||||
await using var verify = CreateContext();
|
||||
var rows = await verify.Set<AuditEvent>()
|
||||
var rows = await verify.Set<AuditLogRow>()
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.ToListAsync();
|
||||
|
||||
@@ -325,16 +327,14 @@ WHERE name = 'UX_AuditLog_EventId'
|
||||
var freshEventId = Guid.NewGuid();
|
||||
var freshOccurred = new DateTime(2026, 5, 15, 12, 0, 0, DateTimeKind.Utc);
|
||||
var freshSite = "purge-idem-fresh-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||
var freshEvt = new AuditEvent
|
||||
{
|
||||
EventId = freshEventId,
|
||||
OccurredAtUtc = freshOccurred,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
Status = AuditStatus.Delivered,
|
||||
SourceSiteId = freshSite,
|
||||
Target = "system-x/method",
|
||||
};
|
||||
var freshEvt = ScadaBridgeAuditEventFactory.Create(
|
||||
eventId: freshEventId,
|
||||
occurredAtUtc: freshOccurred,
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
kind: AuditKind.ApiCall,
|
||||
status: AuditStatus.Delivered,
|
||||
sourceSiteId: freshSite,
|
||||
target: "system-x/method");
|
||||
|
||||
await using (var ctx = CreateContext())
|
||||
{
|
||||
@@ -345,7 +345,7 @@ WHERE name = 'UX_AuditLog_EventId'
|
||||
}
|
||||
|
||||
await using var verify = CreateContext();
|
||||
var rows = await verify.Set<AuditEvent>()
|
||||
var rows = await verify.Set<AuditLogRow>()
|
||||
.Where(e => e.SourceSiteId == freshSite)
|
||||
.ToListAsync();
|
||||
Assert.Single(rows);
|
||||
|
||||
+10
-12
@@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
@@ -67,16 +67,14 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrati
|
||||
return new ScadaBridgeDbContext(options);
|
||||
}
|
||||
|
||||
private static AuditEvent NewEvent(string siteId, Guid? id = null) => new()
|
||||
{
|
||||
EventId = id ?? Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTime.UtcNow,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
Status = AuditStatus.Delivered,
|
||||
SourceSiteId = siteId,
|
||||
Target = "external-system-a/method",
|
||||
};
|
||||
private static AuditEvent NewEvent(string siteId, Guid? id = null) => ScadaBridgeAuditEventFactory.Create(
|
||||
eventId: id ?? Guid.NewGuid(),
|
||||
occurredAtUtc: DateTime.UtcNow,
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
kind: AuditKind.ApiCall,
|
||||
status: AuditStatus.Delivered,
|
||||
sourceSiteId: siteId,
|
||||
target: "external-system-a/method");
|
||||
|
||||
private static IOptions<SqliteAuditWriterOptions> InMemorySqliteOptions() =>
|
||||
Options.Create(new SqliteAuditWriterOptions
|
||||
@@ -167,7 +165,7 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrati
|
||||
Assert.Single(rows);
|
||||
Assert.Equal(evt.EventId, rows[0].EventId);
|
||||
// Central stamps IngestedAtUtc; site never sets it.
|
||||
Assert.NotNull(rows[0].IngestedAtUtc);
|
||||
Assert.NotNull(rows[0].AsRow().IngestedAtUtc);
|
||||
}, TimeSpan.FromSeconds(15));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
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><redacted></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><redacted: redactor error></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;
|
||||
}
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
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>"<redacted>"</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;
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
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<T></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;
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
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><redacted: redactor error></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);
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
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><redacted></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;
|
||||
}
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
@@ -15,17 +17,13 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
/// </summary>
|
||||
public class FallbackAuditWriterTests
|
||||
{
|
||||
private static AuditEvent NewEvent(string? target = null) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTime.UtcNow,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
Status = AuditStatus.Delivered,
|
||||
Target = target,
|
||||
PayloadTruncated = false,
|
||||
ForwardState = AuditForwardState.Pending,
|
||||
};
|
||||
private static AuditEvent NewEvent(string? target = null) => ScadaBridgeAuditEventFactory.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
occurredAtUtc: DateTime.UtcNow,
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
kind: AuditKind.ApiCall,
|
||||
status: AuditStatus.Delivered,
|
||||
target: target);
|
||||
|
||||
/// <summary>Flip-switch primary writer mock.</summary>
|
||||
private sealed class FlipSwitchPrimary : IAuditWriter
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
@@ -13,17 +14,13 @@ public class RingBufferFallbackTests
|
||||
{
|
||||
private static AuditEvent NewEvent(string? target = null)
|
||||
{
|
||||
return new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTime.UtcNow,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
Status = AuditStatus.Delivered,
|
||||
Target = target,
|
||||
PayloadTruncated = false,
|
||||
ForwardState = AuditForwardState.Pending,
|
||||
};
|
||||
return ScadaBridgeAuditEventFactory.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
occurredAtUtc: DateTime.UtcNow,
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
kind: AuditKind.ApiCall,
|
||||
status: AuditStatus.Delivered,
|
||||
target: target);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+8
-10
@@ -2,7 +2,8 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
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.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
@@ -44,15 +45,12 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable
|
||||
new FakeNodeIdentityProvider());
|
||||
}
|
||||
|
||||
private static AuditEvent NewEvent(DateTime? occurredAtUtc = null) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = occurredAtUtc ?? DateTime.UtcNow,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
Status = AuditStatus.Delivered,
|
||||
PayloadTruncated = false,
|
||||
};
|
||||
private static AuditEvent NewEvent(DateTime? occurredAtUtc = null) => ScadaBridgeAuditEventFactory.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
occurredAtUtc: occurredAtUtc ?? DateTime.UtcNow,
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
kind: AuditKind.ApiCall,
|
||||
status: AuditStatus.Delivered);
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyDb_Returns_Zero_Null_AndZeroBytes()
|
||||
|
||||
@@ -3,7 +3,8 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
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.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
@@ -237,21 +238,18 @@ public class SqliteAuditWriterSchemaTests
|
||||
// A WriteAsync binding $ExecutionId must now succeed and round-trip;
|
||||
// without the ALTER it would fail with "no such column: ExecutionId"
|
||||
// and — because audit writes are best-effort — silently drop the row.
|
||||
var evt = new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTime.UtcNow,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
Status = AuditStatus.Delivered,
|
||||
PayloadTruncated = false,
|
||||
ExecutionId = executionId,
|
||||
};
|
||||
var evt = ScadaBridgeAuditEventFactory.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
occurredAtUtc: DateTime.UtcNow,
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
kind: AuditKind.ApiCall,
|
||||
status: AuditStatus.Delivered,
|
||||
executionId: executionId);
|
||||
await writer.WriteAsync(evt);
|
||||
|
||||
var rows = await writer.ReadPendingAsync(limit: 10);
|
||||
var row = Assert.Single(rows);
|
||||
Assert.Equal(executionId, row.ExecutionId);
|
||||
Assert.Equal(executionId, row.AsRow().ExecutionId);
|
||||
}
|
||||
|
||||
// Idempotency: a second writer over the now-upgraded DB must not error
|
||||
@@ -343,23 +341,20 @@ public class SqliteAuditWriterSchemaTests
|
||||
// round-trip; without the ALTER it would fail with "no such column:
|
||||
// ParentExecutionId" and — because audit writes are best-effort —
|
||||
// silently drop the row.
|
||||
var evt = new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTime.UtcNow,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
Status = AuditStatus.Delivered,
|
||||
PayloadTruncated = false,
|
||||
ExecutionId = executionId,
|
||||
ParentExecutionId = parentExecutionId,
|
||||
};
|
||||
var evt = ScadaBridgeAuditEventFactory.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
occurredAtUtc: DateTime.UtcNow,
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
kind: AuditKind.ApiCall,
|
||||
status: AuditStatus.Delivered,
|
||||
executionId: executionId,
|
||||
parentExecutionId: parentExecutionId);
|
||||
await writer.WriteAsync(evt);
|
||||
|
||||
var rows = await writer.ReadPendingAsync(limit: 10);
|
||||
var row = Assert.Single(rows);
|
||||
Assert.Equal(executionId, row.ExecutionId);
|
||||
Assert.Equal(parentExecutionId, row.ParentExecutionId);
|
||||
Assert.Equal(executionId, row.AsRow().ExecutionId);
|
||||
Assert.Equal(parentExecutionId, row.AsRow().ParentExecutionId);
|
||||
}
|
||||
|
||||
// Idempotency: a second writer over the now-upgraded DB must not error
|
||||
@@ -376,21 +371,18 @@ public class SqliteAuditWriterSchemaTests
|
||||
var (writer, _) = CreateWriter(nameof(WriteAsync_NullParentExecutionId_RoundTripsAsNull));
|
||||
await using (writer)
|
||||
{
|
||||
var evt = new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTime.UtcNow,
|
||||
Channel = AuditChannel.Notification,
|
||||
Kind = AuditKind.NotifySend,
|
||||
Status = AuditStatus.Submitted,
|
||||
PayloadTruncated = false,
|
||||
// ParentExecutionId left null
|
||||
};
|
||||
var evt = ScadaBridgeAuditEventFactory.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
occurredAtUtc: DateTime.UtcNow,
|
||||
channel: AuditChannel.Notification,
|
||||
kind: AuditKind.NotifySend,
|
||||
status: AuditStatus.Submitted);
|
||||
// ParentExecutionId left null (not a factory arg → defaults null)
|
||||
await writer.WriteAsync(evt);
|
||||
|
||||
var rows = await writer.ReadPendingAsync(limit: 10);
|
||||
var row = Assert.Single(rows);
|
||||
Assert.Null(row.ParentExecutionId);
|
||||
Assert.Null(row.AsRow().ParentExecutionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,16 +465,13 @@ public class SqliteAuditWriterSchemaTests
|
||||
// A WriteAsync binding $SourceNode must now succeed and round-trip;
|
||||
// without the ALTER it would fail with "no such column: SourceNode"
|
||||
// and — because audit writes are best-effort — silently drop the row.
|
||||
var evt = new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTime.UtcNow,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
Status = AuditStatus.Delivered,
|
||||
PayloadTruncated = false,
|
||||
SourceNode = "node-a",
|
||||
};
|
||||
var evt = ScadaBridgeAuditEventFactory.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
occurredAtUtc: DateTime.UtcNow,
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
kind: AuditKind.ApiCall,
|
||||
status: AuditStatus.Delivered,
|
||||
sourceNode: "node-a");
|
||||
await writer.WriteAsync(evt);
|
||||
|
||||
var rows = await writer.ReadPendingAsync(limit: 10);
|
||||
@@ -504,16 +493,13 @@ public class SqliteAuditWriterSchemaTests
|
||||
var (writer, _) = CreateWriter(nameof(WriteAsync_persists_SourceNode_field));
|
||||
await using (writer)
|
||||
{
|
||||
var evt = new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTime.UtcNow,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
Status = AuditStatus.Delivered,
|
||||
PayloadTruncated = false,
|
||||
SourceNode = "node-a",
|
||||
};
|
||||
var evt = ScadaBridgeAuditEventFactory.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
occurredAtUtc: DateTime.UtcNow,
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
kind: AuditKind.ApiCall,
|
||||
status: AuditStatus.Delivered,
|
||||
sourceNode: "node-a");
|
||||
await writer.WriteAsync(evt);
|
||||
|
||||
var rows = await writer.ReadPendingAsync(limit: 10);
|
||||
@@ -528,16 +514,13 @@ public class SqliteAuditWriterSchemaTests
|
||||
var (writer, _) = CreateWriter(nameof(WriteAsync_persists_null_SourceNode));
|
||||
await using (writer)
|
||||
{
|
||||
var evt = new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTime.UtcNow,
|
||||
Channel = AuditChannel.Notification,
|
||||
Kind = AuditKind.NotifySend,
|
||||
Status = AuditStatus.Submitted,
|
||||
PayloadTruncated = false,
|
||||
// SourceNode left null
|
||||
};
|
||||
var evt = ScadaBridgeAuditEventFactory.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
occurredAtUtc: DateTime.UtcNow,
|
||||
channel: AuditChannel.Notification,
|
||||
kind: AuditKind.NotifySend,
|
||||
status: AuditStatus.Submitted);
|
||||
// SourceNode left null (not a factory arg → defaults null)
|
||||
await writer.WriteAsync(evt);
|
||||
|
||||
var rows = await writer.ReadPendingAsync(limit: 10);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Audit;
|
||||
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.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
@@ -51,18 +52,23 @@ public class SqliteAuditWriterWriteTests
|
||||
return connection;
|
||||
}
|
||||
|
||||
private static AuditEvent NewEvent(Guid? id = null, DateTime? occurredAtUtc = null)
|
||||
{
|
||||
return new AuditEvent
|
||||
{
|
||||
EventId = id ?? Guid.NewGuid(),
|
||||
OccurredAtUtc = occurredAtUtc ?? DateTime.UtcNow,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
Status = AuditStatus.Delivered,
|
||||
PayloadTruncated = false,
|
||||
};
|
||||
}
|
||||
// C3 (Task 2.5): build the canonical ZB.MOM.WW.Audit.AuditEvent via the shared
|
||||
// factory. The SQLite writer's transitional shim decomposes it into the 24 columns
|
||||
// (defaulting ForwardState=Pending) on INSERT and recomposes the canonical record
|
||||
// on read. ExecutionId/SourceNode ride through DetailsJson / the top-level field.
|
||||
private static AuditEvent NewEvent(
|
||||
Guid? id = null,
|
||||
DateTime? occurredAtUtc = null,
|
||||
Guid? executionId = null,
|
||||
string? sourceNode = null)
|
||||
=> ScadaBridgeAuditEventFactory.Create(
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
kind: AuditKind.ApiCall,
|
||||
status: AuditStatus.Delivered,
|
||||
eventId: id ?? Guid.NewGuid(),
|
||||
occurredAtUtc: occurredAtUtc ?? DateTime.UtcNow,
|
||||
executionId: executionId,
|
||||
sourceNode: sourceNode);
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_FreshEvent_PersistsWithForwardStatePending()
|
||||
@@ -134,7 +140,10 @@ public class SqliteAuditWriterWriteTests
|
||||
var (writer, dataSource) = CreateWriter(nameof(WriteAsync_ForcesForwardStatePending_IfNull));
|
||||
await using var _ = writer;
|
||||
|
||||
var evt = NewEvent() with { ForwardState = null };
|
||||
// C3 (Task 2.5): ForwardState is no longer a field on the canonical record;
|
||||
// a fresh canonical event carries none, and the SQLite shim defaults it to
|
||||
// Pending on INSERT — exactly the behaviour this test pins.
|
||||
var evt = NewEvent();
|
||||
await writer.WriteAsync(evt);
|
||||
|
||||
using var connection = OpenVerifierConnection(dataSource);
|
||||
@@ -372,13 +381,13 @@ public class SqliteAuditWriterWriteTests
|
||||
await using var _w = writer;
|
||||
|
||||
var executionId = Guid.NewGuid();
|
||||
var evt = NewEvent() with { ExecutionId = executionId };
|
||||
var evt = NewEvent(executionId: executionId);
|
||||
await writer.WriteAsync(evt);
|
||||
|
||||
var rows = await writer.ReadPendingAsync(limit: 10);
|
||||
|
||||
var row = Assert.Single(rows);
|
||||
Assert.Equal(executionId, row.ExecutionId);
|
||||
Assert.Equal(executionId, row.AsRow().ExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -387,13 +396,13 @@ public class SqliteAuditWriterWriteTests
|
||||
var (writer, _) = CreateWriter(nameof(WriteAsync_NullExecutionId_RoundTripsAsNull));
|
||||
await using var _w = writer;
|
||||
|
||||
var evt = NewEvent() with { ExecutionId = null };
|
||||
var evt = NewEvent(); // executionId defaults to null
|
||||
await writer.WriteAsync(evt);
|
||||
|
||||
var rows = await writer.ReadPendingAsync(limit: 10);
|
||||
|
||||
var row = Assert.Single(rows);
|
||||
Assert.Null(row.ExecutionId);
|
||||
Assert.Null(row.AsRow().ExecutionId);
|
||||
}
|
||||
|
||||
// ----- SourceNode stamping (Tasks 11/12) ----- //
|
||||
@@ -425,7 +434,7 @@ public class SqliteAuditWriterWriteTests
|
||||
|
||||
// Reconciled rows from another node arrive with their origin's
|
||||
// SourceNode already populated; the writer must preserve it.
|
||||
var evt = NewEvent() with { SourceNode = "node-z" };
|
||||
var evt = NewEvent(sourceNode: "node-z");
|
||||
await writer.WriteAsync(evt);
|
||||
|
||||
var rows = await writer.ReadPendingAsync(limit: 10);
|
||||
|
||||
+43
-42
@@ -3,6 +3,7 @@ using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
@@ -67,11 +68,11 @@ public class CachedCallLifecycleBridgeTests
|
||||
httpStatus: 503));
|
||||
|
||||
var packet = Assert.Single(captured);
|
||||
Assert.Equal(AuditKind.ApiCallCached, packet.Audit.Kind);
|
||||
Assert.Equal(AuditStatus.Attempted, packet.Audit.Status);
|
||||
Assert.Equal(503, packet.Audit.HttpStatus);
|
||||
Assert.Equal("HTTP 503", packet.Audit.ErrorMessage);
|
||||
Assert.Equal(_id.Value, packet.Audit.CorrelationId);
|
||||
Assert.Equal(AuditKind.ApiCallCached, packet.Audit.AsRow().Kind);
|
||||
Assert.Equal(AuditStatus.Attempted, packet.Audit.AsRow().Status);
|
||||
Assert.Equal(503, packet.Audit.AsRow().HttpStatus);
|
||||
Assert.Equal("HTTP 503", packet.Audit.AsRow().ErrorMessage);
|
||||
Assert.Equal(_id.Value, packet.Audit.AsRow().CorrelationId);
|
||||
Assert.Equal("Attempted", packet.Operational.Status);
|
||||
Assert.Equal(2, packet.Operational.RetryCount);
|
||||
Assert.Null(packet.Operational.TerminalAtUtc);
|
||||
@@ -90,17 +91,17 @@ public class CachedCallLifecycleBridgeTests
|
||||
Assert.Equal(2, captured.Count);
|
||||
|
||||
var attempted = captured[0];
|
||||
Assert.Equal(AuditKind.ApiCallCached, attempted.Audit.Kind);
|
||||
Assert.Equal(AuditStatus.Attempted, attempted.Audit.Status);
|
||||
Assert.Equal(AuditKind.ApiCallCached, attempted.Audit.AsRow().Kind);
|
||||
Assert.Equal(AuditStatus.Attempted, attempted.Audit.AsRow().Status);
|
||||
Assert.Equal("Attempted", attempted.Operational.Status);
|
||||
Assert.Null(attempted.Operational.TerminalAtUtc);
|
||||
|
||||
var resolve = captured[1];
|
||||
Assert.Equal(AuditKind.CachedResolve, resolve.Audit.Kind);
|
||||
Assert.Equal(AuditStatus.Delivered, resolve.Audit.Status);
|
||||
Assert.Equal(AuditKind.CachedResolve, resolve.Audit.AsRow().Kind);
|
||||
Assert.Equal(AuditStatus.Delivered, resolve.Audit.AsRow().Status);
|
||||
Assert.Equal("Delivered", resolve.Operational.Status);
|
||||
Assert.NotNull(resolve.Operational.TerminalAtUtc);
|
||||
Assert.Equal(_id.Value, resolve.Audit.CorrelationId);
|
||||
Assert.Equal(_id.Value, resolve.Audit.AsRow().CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -116,9 +117,9 @@ public class CachedCallLifecycleBridgeTests
|
||||
lastError: "Permanent failure (handler returned false)"));
|
||||
|
||||
Assert.Equal(2, captured.Count);
|
||||
Assert.Equal(AuditKind.ApiCallCached, captured[0].Audit.Kind);
|
||||
Assert.Equal(AuditKind.CachedResolve, captured[1].Audit.Kind);
|
||||
Assert.Equal(AuditStatus.Parked, captured[1].Audit.Status);
|
||||
Assert.Equal(AuditKind.ApiCallCached, captured[0].Audit.AsRow().Kind);
|
||||
Assert.Equal(AuditKind.CachedResolve, captured[1].Audit.AsRow().Kind);
|
||||
Assert.Equal(AuditStatus.Parked, captured[1].Audit.AsRow().Status);
|
||||
Assert.Equal("Parked", captured[1].Operational.Status);
|
||||
}
|
||||
|
||||
@@ -133,8 +134,8 @@ public class CachedCallLifecycleBridgeTests
|
||||
await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.ParkedMaxRetries));
|
||||
|
||||
Assert.Equal(2, captured.Count);
|
||||
Assert.Equal(AuditKind.CachedResolve, captured[1].Audit.Kind);
|
||||
Assert.Equal(AuditStatus.Parked, captured[1].Audit.Status);
|
||||
Assert.Equal(AuditKind.CachedResolve, captured[1].Audit.AsRow().Kind);
|
||||
Assert.Equal(AuditStatus.Parked, captured[1].Audit.AsRow().Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -149,11 +150,11 @@ public class CachedCallLifecycleBridgeTests
|
||||
CachedCallAttemptOutcome.Delivered, channel: "DbOutbound"));
|
||||
|
||||
Assert.Equal(2, captured.Count);
|
||||
Assert.Equal(AuditKind.DbWriteCached, captured[0].Audit.Kind);
|
||||
Assert.Equal(AuditChannel.DbOutbound, captured[0].Audit.Channel);
|
||||
Assert.Equal(AuditKind.DbWriteCached, captured[0].Audit.AsRow().Kind);
|
||||
Assert.Equal(AuditChannel.DbOutbound, captured[0].Audit.AsRow().Channel);
|
||||
Assert.Equal("DbOutbound", captured[0].Operational.Channel);
|
||||
Assert.Equal(AuditKind.CachedResolve, captured[1].Audit.Kind);
|
||||
Assert.Equal(AuditChannel.DbOutbound, captured[1].Audit.Channel);
|
||||
Assert.Equal(AuditKind.CachedResolve, captured[1].Audit.AsRow().Kind);
|
||||
Assert.Equal(AuditChannel.DbOutbound, captured[1].Audit.AsRow().Channel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -184,11 +185,11 @@ public class CachedCallLifecycleBridgeTests
|
||||
httpStatus: 500));
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.Equal("site-77", captured!.Audit.SourceSiteId);
|
||||
Assert.Equal("Plant.Pump42", captured.Audit.SourceInstanceId);
|
||||
Assert.Equal("ERP.GetOrder", captured.Audit.Target);
|
||||
Assert.Equal(42, captured.Audit.DurationMs);
|
||||
Assert.Equal(_id.Value, captured.Audit.CorrelationId);
|
||||
Assert.Equal("site-77", captured!.Audit.AsRow().SourceSiteId);
|
||||
Assert.Equal("Plant.Pump42", captured.Audit.AsRow().SourceInstanceId);
|
||||
Assert.Equal("ERP.GetOrder", captured.Audit.AsRow().Target);
|
||||
Assert.Equal(42, captured.Audit.AsRow().DurationMs);
|
||||
Assert.Equal(_id.Value, captured.Audit.AsRow().CorrelationId);
|
||||
}
|
||||
|
||||
// ── Audit Log #23 (ExecutionId Task 4): ExecutionId / SourceScript ──
|
||||
@@ -212,9 +213,9 @@ public class CachedCallLifecycleBridgeTests
|
||||
sourceScript: "Plant.Pump42/OnTick"));
|
||||
|
||||
var packet = Assert.Single(captured);
|
||||
Assert.Equal(AuditKind.ApiCallCached, packet.Audit.Kind);
|
||||
Assert.Equal(executionId, packet.Audit.ExecutionId);
|
||||
Assert.Equal("Plant.Pump42/OnTick", packet.Audit.SourceScript);
|
||||
Assert.Equal(AuditKind.ApiCallCached, packet.Audit.AsRow().Kind);
|
||||
Assert.Equal(executionId, packet.Audit.AsRow().ExecutionId);
|
||||
Assert.Equal("Plant.Pump42/OnTick", packet.Audit.AsRow().SourceScript);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -235,13 +236,13 @@ public class CachedCallLifecycleBridgeTests
|
||||
sourceScript: "Plant.Tank/OnAlarm"));
|
||||
|
||||
Assert.Equal(2, captured.Count);
|
||||
var resolve = Assert.Single(captured, p => p.Audit.Kind == AuditKind.CachedResolve);
|
||||
Assert.Equal(executionId, resolve.Audit.ExecutionId);
|
||||
Assert.Equal("Plant.Tank/OnAlarm", resolve.Audit.SourceScript);
|
||||
var resolve = Assert.Single(captured, p => p.Audit.AsRow().Kind == AuditKind.CachedResolve);
|
||||
Assert.Equal(executionId, resolve.Audit.AsRow().ExecutionId);
|
||||
Assert.Equal("Plant.Tank/OnAlarm", resolve.Audit.AsRow().SourceScript);
|
||||
|
||||
var attempted = Assert.Single(captured, p => p.Audit.Kind == AuditKind.DbWriteCached);
|
||||
Assert.Equal(executionId, attempted.Audit.ExecutionId);
|
||||
Assert.Equal("Plant.Tank/OnAlarm", attempted.Audit.SourceScript);
|
||||
var attempted = Assert.Single(captured, p => p.Audit.AsRow().Kind == AuditKind.DbWriteCached);
|
||||
Assert.Equal(executionId, attempted.Audit.AsRow().ExecutionId);
|
||||
Assert.Equal("Plant.Tank/OnAlarm", attempted.Audit.AsRow().SourceScript);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -258,8 +259,8 @@ public class CachedCallLifecycleBridgeTests
|
||||
await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.TransientFailure));
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.Null(captured!.Audit.ExecutionId);
|
||||
Assert.Null(captured.Audit.SourceScript);
|
||||
Assert.Null(captured!.Audit.AsRow().ExecutionId);
|
||||
Assert.Null(captured.Audit.AsRow().SourceScript);
|
||||
}
|
||||
|
||||
// ── Audit Log #23 (ParentExecutionId Task 6): ParentExecutionId ──
|
||||
@@ -282,8 +283,8 @@ public class CachedCallLifecycleBridgeTests
|
||||
parentExecutionId: parentExecutionId));
|
||||
|
||||
var packet = Assert.Single(captured);
|
||||
Assert.Equal(AuditKind.ApiCallCached, packet.Audit.Kind);
|
||||
Assert.Equal(parentExecutionId, packet.Audit.ParentExecutionId);
|
||||
Assert.Equal(AuditKind.ApiCallCached, packet.Audit.AsRow().Kind);
|
||||
Assert.Equal(parentExecutionId, packet.Audit.AsRow().ParentExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -304,11 +305,11 @@ public class CachedCallLifecycleBridgeTests
|
||||
parentExecutionId: parentExecutionId));
|
||||
|
||||
Assert.Equal(2, captured.Count);
|
||||
var resolve = Assert.Single(captured, p => p.Audit.Kind == AuditKind.CachedResolve);
|
||||
Assert.Equal(parentExecutionId, resolve.Audit.ParentExecutionId);
|
||||
var resolve = Assert.Single(captured, p => p.Audit.AsRow().Kind == AuditKind.CachedResolve);
|
||||
Assert.Equal(parentExecutionId, resolve.Audit.AsRow().ParentExecutionId);
|
||||
|
||||
var attempted = Assert.Single(captured, p => p.Audit.Kind == AuditKind.DbWriteCached);
|
||||
Assert.Equal(parentExecutionId, attempted.Audit.ParentExecutionId);
|
||||
var attempted = Assert.Single(captured, p => p.Audit.AsRow().Kind == AuditKind.DbWriteCached);
|
||||
Assert.Equal(parentExecutionId, attempted.Audit.AsRow().ParentExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -325,7 +326,7 @@ public class CachedCallLifecycleBridgeTests
|
||||
await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.TransientFailure));
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.Null(captured!.Audit.ParentExecutionId);
|
||||
Assert.Null(captured!.Audit.AsRow().ParentExecutionId);
|
||||
}
|
||||
|
||||
// ── SourceNode-stamping (Task 14) ──
|
||||
|
||||
+40
-47
@@ -2,7 +2,9 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||
@@ -32,20 +34,17 @@ public class CachedCallTelemetryForwarderTests
|
||||
|
||||
private CachedCallTelemetry SubmitPacket() =>
|
||||
new(
|
||||
Audit: new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = _now,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.CachedSubmit,
|
||||
CorrelationId = _id.Value,
|
||||
SourceSiteId = "site-1",
|
||||
SourceInstanceId = "inst-1",
|
||||
SourceScript = "ScriptActor:doStuff",
|
||||
Target = "ERP.GetOrder",
|
||||
Status = AuditStatus.Submitted,
|
||||
ForwardState = AuditForwardState.Pending,
|
||||
},
|
||||
Audit: ScadaBridgeAuditEventFactory.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
occurredAtUtc: _now,
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
kind: AuditKind.CachedSubmit,
|
||||
correlationId: _id.Value,
|
||||
sourceSiteId: "site-1",
|
||||
sourceInstanceId: "inst-1",
|
||||
sourceScript: "ScriptActor:doStuff",
|
||||
target: "ERP.GetOrder",
|
||||
status: AuditStatus.Submitted),
|
||||
Operational: new SiteCallOperational(
|
||||
TrackedOperationId: _id,
|
||||
Channel: "ApiOutbound",
|
||||
@@ -62,20 +61,17 @@ public class CachedCallTelemetryForwarderTests
|
||||
|
||||
private CachedCallTelemetry AttemptedPacket(int retryCount = 1, string? lastError = "HTTP 500", int? httpStatus = 500) =>
|
||||
new(
|
||||
Audit: new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = _now,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCallCached,
|
||||
CorrelationId = _id.Value,
|
||||
SourceSiteId = "site-1",
|
||||
Target = "ERP.GetOrder",
|
||||
Status = AuditStatus.Attempted,
|
||||
HttpStatus = httpStatus,
|
||||
ErrorMessage = lastError,
|
||||
ForwardState = AuditForwardState.Pending,
|
||||
},
|
||||
Audit: ScadaBridgeAuditEventFactory.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
occurredAtUtc: _now,
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
kind: AuditKind.ApiCallCached,
|
||||
correlationId: _id.Value,
|
||||
sourceSiteId: "site-1",
|
||||
target: "ERP.GetOrder",
|
||||
status: AuditStatus.Attempted,
|
||||
httpStatus: httpStatus,
|
||||
errorMessage: lastError),
|
||||
Operational: new SiteCallOperational(
|
||||
TrackedOperationId: _id,
|
||||
Channel: "ApiOutbound",
|
||||
@@ -92,18 +88,15 @@ public class CachedCallTelemetryForwarderTests
|
||||
|
||||
private CachedCallTelemetry ResolvePacket(string status = "Delivered") =>
|
||||
new(
|
||||
Audit: new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = _now,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.CachedResolve,
|
||||
CorrelationId = _id.Value,
|
||||
SourceSiteId = "site-1",
|
||||
Target = "ERP.GetOrder",
|
||||
Status = Enum.Parse<AuditStatus>(status),
|
||||
ForwardState = AuditForwardState.Pending,
|
||||
},
|
||||
Audit: ScadaBridgeAuditEventFactory.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
occurredAtUtc: _now,
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
kind: AuditKind.CachedResolve,
|
||||
correlationId: _id.Value,
|
||||
sourceSiteId: "site-1",
|
||||
target: "ERP.GetOrder",
|
||||
status: Enum.Parse<AuditStatus>(status)),
|
||||
Operational: new SiteCallOperational(
|
||||
TrackedOperationId: _id,
|
||||
Channel: "ApiOutbound",
|
||||
@@ -130,8 +123,8 @@ public class CachedCallTelemetryForwarderTests
|
||||
await _writer.Received(1).WriteAsync(
|
||||
Arg.Is<AuditEvent>(e =>
|
||||
e.EventId == packet.Audit.EventId
|
||||
&& e.Kind == AuditKind.CachedSubmit
|
||||
&& e.Status == AuditStatus.Submitted),
|
||||
&& e.AsRow().Kind == AuditKind.CachedSubmit
|
||||
&& e.AsRow().Status == AuditStatus.Submitted),
|
||||
Arg.Any<CancellationToken>());
|
||||
|
||||
// Tracking row: insert-if-not-exists with kind discriminator.
|
||||
@@ -165,8 +158,8 @@ public class CachedCallTelemetryForwarderTests
|
||||
await _writer.Received(1).WriteAsync(
|
||||
Arg.Is<AuditEvent>(e =>
|
||||
e.EventId == packet.Audit.EventId
|
||||
&& e.Kind == AuditKind.ApiCallCached
|
||||
&& e.Status == AuditStatus.Attempted),
|
||||
&& e.AsRow().Kind == AuditKind.ApiCallCached
|
||||
&& e.AsRow().Status == AuditStatus.Attempted),
|
||||
Arg.Any<CancellationToken>());
|
||||
|
||||
await _tracking.Received(1).RecordAttemptAsync(
|
||||
@@ -188,8 +181,8 @@ public class CachedCallTelemetryForwarderTests
|
||||
await _writer.Received(1).WriteAsync(
|
||||
Arg.Is<AuditEvent>(e =>
|
||||
e.EventId == packet.Audit.EventId
|
||||
&& e.Kind == AuditKind.CachedResolve
|
||||
&& e.Status == AuditStatus.Delivered),
|
||||
&& e.AsRow().Kind == AuditKind.CachedResolve
|
||||
&& e.AsRow().Status == AuditStatus.Delivered),
|
||||
Arg.Any<CancellationToken>());
|
||||
|
||||
await _tracking.Received(1).RecordTerminalAsync(
|
||||
|
||||
+9
-11
@@ -2,7 +2,8 @@ using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
@@ -29,16 +30,13 @@ public class ClusterClientSiteAuditClientTests : TestKit
|
||||
/// <summary>Short Ask timeout so the timeout test completes quickly.</summary>
|
||||
private static readonly TimeSpan AskTimeout = TimeSpan.FromMilliseconds(500);
|
||||
|
||||
private static AuditEvent NewEvent(Guid? id = null) => new()
|
||||
{
|
||||
EventId = id ?? Guid.NewGuid(),
|
||||
OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
Status = AuditStatus.Delivered,
|
||||
SourceSiteId = "site-1",
|
||||
ForwardState = AuditForwardState.Pending,
|
||||
};
|
||||
private static AuditEvent NewEvent(Guid? id = null) => ScadaBridgeAuditEventFactory.Create(
|
||||
eventId: id ?? Guid.NewGuid(),
|
||||
occurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
kind: AuditKind.ApiCall,
|
||||
status: AuditStatus.Delivered,
|
||||
sourceSiteId: "site-1");
|
||||
|
||||
private static AuditEventBatch BatchOf(IEnumerable<AuditEvent> events)
|
||||
{
|
||||
|
||||
+21
-23
@@ -6,7 +6,8 @@ using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
@@ -66,16 +67,13 @@ public class SiteAuditTelemetryActorTests : TestKit
|
||||
NullLogger<SiteAuditTelemetryActor>.Instance,
|
||||
(IOperationTrackingStore?)_trackingStore)));
|
||||
|
||||
private static AuditEvent NewEvent(Guid? id = null) => new()
|
||||
{
|
||||
EventId = id ?? Guid.NewGuid(),
|
||||
OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
Status = AuditStatus.Delivered,
|
||||
SourceSiteId = "site-1",
|
||||
ForwardState = AuditForwardState.Pending,
|
||||
};
|
||||
private static AuditEvent NewEvent(Guid? id = null) => ScadaBridgeAuditEventFactory.Create(
|
||||
eventId: id ?? Guid.NewGuid(),
|
||||
occurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
kind: AuditKind.ApiCall,
|
||||
status: AuditStatus.Delivered,
|
||||
sourceSiteId: "site-1");
|
||||
|
||||
private static IngestAck AckAll(IReadOnlyList<AuditEvent> events)
|
||||
{
|
||||
@@ -265,18 +263,18 @@ public class SiteAuditTelemetryActorTests : TestKit
|
||||
AuditKind kind = AuditKind.CachedSubmit,
|
||||
Guid? eventId = null,
|
||||
Guid? correlationId = null,
|
||||
string sourceSiteId = "site-1") => new()
|
||||
{
|
||||
EventId = eventId ?? Guid.NewGuid(),
|
||||
OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = kind,
|
||||
Status = AuditStatus.Submitted,
|
||||
SourceSiteId = sourceSiteId,
|
||||
Target = "ERP.GetOrder",
|
||||
CorrelationId = correlationId ?? Guid.NewGuid(),
|
||||
ForwardState = AuditForwardState.Pending,
|
||||
};
|
||||
string sourceSiteId = "site-1") =>
|
||||
// C3 (Task 2.5): canonical record via the shared factory; ForwardState is
|
||||
// no longer a record field (the SQLite shim defaults it on INSERT).
|
||||
ScadaBridgeAuditEventFactory.Create(
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
kind: kind,
|
||||
status: AuditStatus.Submitted,
|
||||
eventId: eventId ?? Guid.NewGuid(),
|
||||
occurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
||||
target: "ERP.GetOrder",
|
||||
sourceSiteId: sourceSiteId,
|
||||
correlationId: correlationId ?? Guid.NewGuid());
|
||||
|
||||
private static TrackingStatusSnapshot NewSnapshot(
|
||||
TrackedOperationId id,
|
||||
|
||||
Reference in New Issue
Block a user