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:
Joseph Doherty
2026-06-02 12:37:50 -04:00
parent 5aaf9e2923
commit db707bb0de
127 changed files with 2240 additions and 3886 deletions
@@ -1,7 +1,7 @@
using System.Text;
using NSubstitute;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
@@ -22,31 +22,22 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
/// </summary>
public class AuditLogExportServiceTests
{
// C3 (Task 2.5): the export service reads canonical ZB.MOM.WW.Audit.AuditEvent rows
// from IAuditLogRepository and decomposes each into the flat 21-column CSV shape;
// build the canonical rows via the shared factory (domain fields ride in DetailsJson).
private static AuditEvent SimpleEvent(string id, string? target = null, string? error = null)
=> new()
{
EventId = Guid.Parse(id),
OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
IngestedAtUtc = new DateTime(2026, 5, 20, 12, 0, 1, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
CorrelationId = null,
SourceSiteId = "plant-a",
SourceInstanceId = null,
SourceScript = null,
Actor = null,
Target = target,
Status = AuditStatus.Delivered,
HttpStatus = 200,
DurationMs = 42,
ErrorMessage = error,
ErrorDetail = null,
RequestSummary = null,
ResponseSummary = null,
PayloadTruncated = false,
Extra = null,
ForwardState = null,
};
=> ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.ApiOutbound,
kind: AuditKind.ApiCall,
status: AuditStatus.Delivered,
eventId: Guid.Parse(id),
occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
target: target,
sourceSiteId: "plant-a",
httpStatus: 200,
durationMs: 42,
errorMessage: error,
ingestedAtUtc: new DateTimeOffset(new DateTime(2026, 5, 20, 12, 0, 1, DateTimeKind.Utc)));
[Fact]
public async Task ExportAsync_FiveRows_WritesHeaderPlusFiveRows()
@@ -115,30 +106,16 @@ public class AuditLogExportServiceTests
// Target contains a comma → field must be wrapped in double quotes.
// Target with embedded quote → quote must be doubled ("") and field quoted.
// ResponseSummary contains CR-LF → field must be quoted.
var row = new AuditEvent
{
EventId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
IngestedAtUtc = null,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
CorrelationId = null,
SourceSiteId = "plant-a, secondary", // comma
SourceInstanceId = null,
SourceScript = "say \"hi\"", // embedded quote
Actor = null,
Target = "x",
Status = AuditStatus.Delivered,
HttpStatus = null,
DurationMs = null,
ErrorMessage = "boom\r\nthen again", // CR-LF
ErrorDetail = null,
RequestSummary = null,
ResponseSummary = null,
PayloadTruncated = false,
Extra = null,
ForwardState = null,
};
var row = ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.ApiOutbound,
kind: AuditKind.ApiCall,
status: AuditStatus.Delivered,
eventId: Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
target: "x",
sourceSiteId: "plant-a, secondary", // comma
sourceScript: "say \"hi\"", // embedded quote
errorMessage: "boom\r\nthen again"); // CR-LF
var repo = Substitute.For<IAuditLogRepository>();
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(
@@ -163,30 +140,12 @@ public class AuditLogExportServiceTests
public async Task ExportAsync_NullField_WrittenAsEmpty()
{
// Build a row with deliberate nulls for every nullable column.
var row = new AuditEvent
{
EventId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
IngestedAtUtc = null,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
CorrelationId = null,
SourceSiteId = null,
SourceInstanceId = null,
SourceScript = null,
Actor = null,
Target = null,
Status = AuditStatus.Submitted,
HttpStatus = null,
DurationMs = null,
ErrorMessage = null,
ErrorDetail = null,
RequestSummary = null,
ResponseSummary = null,
PayloadTruncated = false,
Extra = null,
ForwardState = null,
};
var row = ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.ApiOutbound,
kind: AuditKind.ApiCall,
status: AuditStatus.Submitted,
eventId: Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc));
var repo = Substitute.For<IAuditLogRepository>();
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(
@@ -278,14 +237,20 @@ public class AuditLogExportServiceTests
{
// Two pages of 2 rows each, then empty. The service must pass the last
// row of page 1 as the cursor on the page-2 call.
AuditEvent Row(string id, DateTime occurredAt) => ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.ApiOutbound,
kind: AuditKind.ApiCall,
status: AuditStatus.Delivered,
eventId: Guid.Parse(id),
occurredAtUtc: occurredAt);
var p1 = new List<AuditEvent>
{
new() { EventId = Guid.Parse("11111111-1111-1111-1111-111111111111"), OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered },
new() { EventId = Guid.Parse("22222222-2222-2222-2222-222222222222"), OccurredAtUtc = new DateTime(2026, 5, 20, 11, 59, 0, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered },
Row("11111111-1111-1111-1111-111111111111", new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc)),
Row("22222222-2222-2222-2222-222222222222", new DateTime(2026, 5, 20, 11, 59, 0, DateTimeKind.Utc)),
};
var p2 = new List<AuditEvent>
{
new() { EventId = Guid.Parse("33333333-3333-3333-3333-333333333333"), OccurredAtUtc = new DateTime(2026, 5, 20, 11, 58, 0, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered },
Row("33333333-3333-3333-3333-333333333333", new DateTime(2026, 5, 20, 11, 58, 0, DateTimeKind.Utc)),
};
var pagings = new List<AuditLogPaging>();
@@ -305,6 +270,8 @@ public class AuditLogExportServiceTests
Assert.Null(pagings[0].AfterEventId);
Assert.Null(pagings[0].AfterOccurredAtUtc);
Assert.Equal(p1[^1].EventId, pagings[1].AfterEventId);
Assert.Equal(p1[^1].OccurredAtUtc, pagings[1].AfterOccurredAtUtc);
// Canonical OccurredAtUtc is a DateTimeOffset; the paging cursor is a DateTime —
// compare via the decomposed row view (Kind=Utc DateTime).
Assert.Equal(p1[^1].AsRow().OccurredAtUtc, pagings[1].AfterOccurredAtUtc);
}
}