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
@@ -13,9 +13,9 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.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;
@@ -38,17 +38,17 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Audit;
/// </summary>
public class AuditExportEndpointsTests
{
private static AuditEvent SampleEvent() => new()
{
EventId = Guid.Parse("11111111-1111-1111-1111-111111111111"),
OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
IngestedAtUtc = null,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
SourceSiteId = "plant-a",
Status = AuditStatus.Delivered,
HttpStatus = 200,
};
// C3 (Task 2.5): the export endpoint reads canonical ZB.MOM.WW.Audit.AuditEvent
// rows straight off IAuditLogRepository.QueryAsync; build them via the factory.
private static AuditEvent SampleEvent() =>
ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.ApiOutbound,
kind: AuditKind.ApiCall,
status: AuditStatus.Delivered,
eventId: Guid.Parse("11111111-1111-1111-1111-111111111111"),
occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
sourceSiteId: "plant-a",
httpStatus: 200);
/// <summary>
/// Builds a tiny in-process test host that wires the export endpoint to a
@@ -3,7 +3,7 @@ using Bunit.TestDoubles;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit;
@@ -13,7 +13,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit;
///
/// The drawer is a child component opened from the Audit Log page when a grid row
/// is clicked. It renders the offcanvas chrome (header, open/close) and delegates
/// the <see cref="AuditEvent"/> body to the shared <see cref="AuditEventDetail"/>
/// the <see cref="AuditEventView"/> body to the shared <see cref="AuditEventDetail"/>
/// component, which since the recent refactor owns the channel-aware bodies
/// (JSON pretty-print, SQL block for DbOutbound), redaction badges on
/// Request/Response, and conditional action buttons.
@@ -32,7 +32,7 @@ public class AuditDrilldownDrawerTests : BunitContext
JSInterop.Mode = JSRuntimeMode.Loose;
}
private static AuditEvent MakeEvent(
private static AuditEventView MakeEvent(
AuditChannel channel = AuditChannel.ApiOutbound,
AuditKind kind = AuditKind.ApiCall,
AuditStatus status = AuditStatus.Delivered,
@@ -3,7 +3,7 @@ using Bunit.TestDoubles;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit;
@@ -29,7 +29,7 @@ public class AuditEventDetailTests : BunitContext
JSInterop.Mode = JSRuntimeMode.Loose;
}
private static AuditEvent MakeEvent(
private static AuditEventView MakeEvent(
AuditChannel channel = AuditChannel.ApiOutbound,
AuditKind kind = AuditKind.ApiCall,
AuditStatus status = AuditStatus.Delivered,
@@ -4,7 +4,6 @@ using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
@@ -53,7 +52,7 @@ public class AuditFilterBarTests : BunitContext
_auditLogQueryService.GetDistinctSourceNodesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<string>>(new[] { "central-a", "central-b" }));
_auditLogQueryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
.Returns(Task.FromResult<IReadOnlyList<AuditEventView>>(Array.Empty<AuditEventView>()));
Services.AddSingleton(_auditLogQueryService);
}
@@ -4,7 +4,6 @@ using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
@@ -22,7 +21,7 @@ public class AuditResultsGridTests : BunitContext
private readonly IAuditLogQueryService _service;
private readonly List<(AuditLogQueryFilter Filter, AuditLogPaging? Paging)> _calls = new();
private static AuditEvent MakeEvent(DateTime occurredAtUtc, AuditStatus status, AuditChannel channel = AuditChannel.ApiOutbound, AuditKind kind = AuditKind.ApiCall, string? site = "plant-a", Guid? executionId = null, Guid? parentExecutionId = null)
private static AuditEventView MakeEvent(DateTime occurredAtUtc, AuditStatus status, AuditChannel channel = AuditChannel.ApiOutbound, AuditKind kind = AuditKind.ApiCall, string? site = "plant-a", Guid? executionId = null, Guid? parentExecutionId = null)
=> new()
{
EventId = Guid.NewGuid(),
@@ -53,7 +52,7 @@ public class AuditResultsGridTests : BunitContext
JSInterop.Mode = JSRuntimeMode.Loose;
}
private void StubPage(IReadOnlyList<AuditEvent> rows)
private void StubPage(IReadOnlyList<AuditEventView> rows)
{
_service.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns(callInfo =>
@@ -66,7 +65,7 @@ public class AuditResultsGridTests : BunitContext
[Fact]
public void Render_TenColumns_FromStubService()
{
StubPage(new List<AuditEvent>
StubPage(new List<AuditEventView>
{
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
});
@@ -112,10 +111,10 @@ public class AuditResultsGridTests : BunitContext
var target = MakeEvent(DateTime.UtcNow.AddMinutes(-5), AuditStatus.Delivered);
StubPage(new[] { target });
AuditEvent? captured = null;
AuditEventView? captured = null;
var cut = Render<AuditResultsGrid>(p => p
.Add(c => c.Filter, new AuditLogQueryFilter())
.Add(c => c.OnRowSelected, EventCallback.Factory.Create<AuditEvent>(this, e => captured = e)));
.Add(c => c.OnRowSelected, EventCallback.Factory.Create<AuditEventView>(this, e => captured = e)));
cut.Find($"[data-test=\"grid-row-{target.EventId}\"]").Click();
@@ -128,7 +127,7 @@ public class AuditResultsGridTests : BunitContext
{
// Task 15: the grid surfaces SourceNode in a dedicated "Node" column
// positioned between Site and Channel.
StubPage(new List<AuditEvent>
StubPage(new List<AuditEventView>
{
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
});
@@ -148,7 +147,7 @@ public class AuditResultsGridTests : BunitContext
[Fact]
public void Render_IncludesExecutionIdColumn()
{
StubPage(new List<AuditEvent>
StubPage(new List<AuditEventView>
{
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
});
@@ -191,7 +190,7 @@ public class AuditResultsGridTests : BunitContext
[Fact]
public void Render_IncludesParentExecutionIdColumn()
{
StubPage(new List<AuditEvent>
StubPage(new List<AuditEventView>
{
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
});
@@ -4,7 +4,6 @@ using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
@@ -36,7 +35,7 @@ public class ExecutionDetailModalTests : BunitContext
JSInterop.Mode = JSRuntimeMode.Loose;
}
private static AuditEvent MakeEvent(
private static AuditEventView MakeEvent(
Guid executionId,
AuditStatus status = AuditStatus.Delivered,
AuditChannel channel = AuditChannel.ApiOutbound,
@@ -57,7 +56,7 @@ public class ExecutionDetailModalTests : BunitContext
HttpStatus = status == AuditStatus.Delivered ? 200 : 500,
};
private void StubRows(IReadOnlyList<AuditEvent> rows)
private void StubRows(IReadOnlyList<AuditEventView> rows)
{
_service.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns(callInfo =>
@@ -202,7 +201,7 @@ public class ExecutionDetailModalTests : BunitContext
public void ZeroRow_ShowsFriendlyEmptyState()
{
var executionId = Guid.NewGuid();
StubRows(Array.Empty<AuditEvent>());
StubRows(Array.Empty<AuditEventView>());
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
@@ -217,7 +216,7 @@ public class ExecutionDetailModalTests : BunitContext
{
var executionId = Guid.NewGuid();
_service.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns<Task<IReadOnlyList<AuditEvent>>>(_ => throw new InvalidOperationException("db is down"));
.Returns<Task<IReadOnlyList<AuditEventView>>>(_ => throw new InvalidOperationException("db is down"));
// Rendering with IsOpen=true must not throw — the modal degrades to an
// inline error banner rather than killing the SignalR circuit.
@@ -18,7 +18,7 @@ using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
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.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
@@ -7,7 +7,6 @@ using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Security;
@@ -176,7 +175,7 @@ public class AuditLogPageScaffoldTests : BunitContext
var corr = Guid.Parse("11111111-2222-3333-4444-555555555555");
_queryService = Substitute.For<IAuditLogQueryService>();
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
.Returns(Task.FromResult<IReadOnlyList<AuditEventView>>(new List<AuditEventView>()));
var cut = RenderAuditLogPageWithQuery($"correlationId={corr}", "Administrator");
@@ -199,7 +198,7 @@ public class AuditLogPageScaffoldTests : BunitContext
var executionId = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
_queryService = Substitute.For<IAuditLogQueryService>();
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
.Returns(Task.FromResult<IReadOnlyList<AuditEventView>>(new List<AuditEventView>()));
var cut = RenderAuditLogPageWithQuery($"executionId={executionId}", "Administrator");
@@ -237,7 +236,7 @@ public class AuditLogPageScaffoldTests : BunitContext
var parentExecutionId = Guid.Parse("aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb");
_queryService = Substitute.For<IAuditLogQueryService>();
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
.Returns(Task.FromResult<IReadOnlyList<AuditEventView>>(new List<AuditEventView>()));
var cut = RenderAuditLogPageWithQuery($"parentExecutionId={parentExecutionId}", "Administrator");
@@ -272,7 +271,7 @@ public class AuditLogPageScaffoldTests : BunitContext
{
_queryService = Substitute.For<IAuditLogQueryService>();
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
.Returns(Task.FromResult<IReadOnlyList<AuditEventView>>(new List<AuditEventView>()));
var cut = RenderAuditLogPageWithQuery("target=ExternalSystem-Alpha", "Administrator");
@@ -290,7 +289,7 @@ public class AuditLogPageScaffoldTests : BunitContext
{
_queryService = Substitute.For<IAuditLogQueryService>();
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
.Returns(Task.FromResult<IReadOnlyList<AuditEventView>>(new List<AuditEventView>()));
var cut = RenderAuditLogPageWithQuery("site=plant-a", "Administrator");
@@ -312,7 +311,7 @@ public class AuditLogPageScaffoldTests : BunitContext
// builds an AuditLogQueryFilter with Status set, and auto-loads.
_queryService = Substitute.For<IAuditLogQueryService>();
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
.Returns(Task.FromResult<IReadOnlyList<AuditEventView>>(new List<AuditEventView>()));
var cut = RenderAuditLogPageWithQuery("status=Failed", "Administrator");
@@ -7,7 +7,6 @@ using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Security;
@@ -127,7 +126,7 @@ public class ExecutionTreePageTests : BunitContext
}));
// The modal loads the double-clicked execution's audit rows on open.
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
.Returns(Task.FromResult<IReadOnlyList<AuditEventView>>(Array.Empty<AuditEventView>()));
// AuditEventDetail (reachable from the modal) owns a clipboard interop call.
JSInterop.Mode = JSRuntimeMode.Loose;
@@ -160,7 +159,7 @@ public class ExecutionTreePageTests : BunitContext
Node(child, root),
}));
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
.Returns(Task.FromResult<IReadOnlyList<AuditEventView>>(Array.Empty<AuditEventView>()));
JSInterop.Mode = JSRuntimeMode.Loose;
var cut = RenderPage($"executionId={child}", "Administrator");
@@ -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);
}
}
@@ -2,8 +2,8 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
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.Messages.Health;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
@@ -36,18 +36,31 @@ public class AuditLogQueryServiceTests
var repo = Substitute.For<IAuditLogRepository>();
var filter = new AuditLogQueryFilter(Channels: new[] { AuditChannel.ApiOutbound });
var paging = new AuditLogPaging(PageSize: 25);
var expected = new List<AuditEvent>
// C3 (Task 2.5): the repository returns canonical ZB.MOM.WW.Audit.AuditEvent rows;
// the service decomposes each into a flat AuditEventView for the UI.
var eventId = Guid.NewGuid();
var repoRows = new List<AuditEvent>
{
new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTime.UtcNow, Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered }
ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.ApiOutbound,
kind: AuditKind.ApiCall,
status: AuditStatus.Delivered,
eventId: eventId),
};
repo.QueryAsync(filter, paging, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(expected));
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(repoRows));
var sut = new AuditLogQueryService(repo, EmptyAggregator());
var result = await sut.QueryAsync(filter, paging);
Assert.Same(expected, result);
// The service projects canonical → AuditEventView (a new list), so assert on
// the decomposed content rather than reference identity.
var view = Assert.Single(result);
Assert.Equal(eventId, view.EventId);
Assert.Equal(AuditChannel.ApiOutbound, view.Channel);
Assert.Equal(AuditKind.ApiCall, view.Kind);
Assert.Equal(AuditStatus.Delivered, view.Status);
await repo.Received(1).QueryAsync(filter, paging, Arg.Any<CancellationToken>());
}