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,9 +1,10 @@
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
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;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
using Xunit;
@@ -42,7 +43,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
// Re-read in a fresh context so we exercise the persisted row, not the
// (already-bypassed) change tracker.
await using var readContext = CreateContext();
var loaded = await readContext.Set<AuditEvent>()
var loaded = await readContext.Set<AuditLogRow>()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
@@ -66,7 +67,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
await repo.InsertIfNotExistsAsync(evt);
await using var readContext = CreateContext();
var loaded = await readContext.Set<AuditEvent>()
var loaded = await readContext.Set<AuditLogRow>()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
@@ -92,7 +93,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
await repo.InsertIfNotExistsAsync(evt);
await using var readContext = CreateContext();
var loaded = await readContext.Set<AuditEvent>()
var loaded = await readContext.Set<AuditLogRow>()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
@@ -114,11 +115,20 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
await repo.InsertIfNotExistsAsync(first);
// Same EventId, different payload — first-write-wins, the second call is silently a no-op.
var second = first with { ErrorMessage = "second-should-be-ignored" };
// C3 (Task 2.5): ErrorMessage rides in DetailsJson on the canonical record, so rebuild
// a sibling row carrying the same EventId via the factory (rather than a top-level `with`).
var second = ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.ApiOutbound,
kind: AuditKind.ApiCall,
status: AuditStatus.Delivered,
eventId: first.EventId,
occurredAtUtc: occurredAt,
sourceSiteId: siteId,
errorMessage: "second-should-be-ignored");
await repo.InsertIfNotExistsAsync(second);
await using var readContext = CreateContext();
var loaded = await readContext.Set<AuditEvent>()
var loaded = await readContext.Set<AuditLogRow>()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
@@ -170,7 +180,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
Assert.All(rows, r => Assert.Equal(AuditChannel.Notification, r.Channel));
Assert.All(rows, r => Assert.Equal(AuditChannel.Notification, r.AsRow().Channel));
}
[SkippableFact]
@@ -196,8 +206,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
Assert.All(rows, r => Assert.Contains(r.Channel, new[] { AuditChannel.ApiOutbound, AuditChannel.Notification }));
Assert.DoesNotContain(rows, r => r.Channel == AuditChannel.DbOutbound);
Assert.All(rows, r => Assert.Contains(r.AsRow().Channel, new[] { AuditChannel.ApiOutbound, AuditChannel.Notification }));
Assert.DoesNotContain(rows, r => r.AsRow().Channel == AuditChannel.DbOutbound);
}
[SkippableFact]
@@ -222,8 +232,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
Assert.All(rows, r => Assert.Contains(r.Status, new[] { AuditStatus.Failed, AuditStatus.Parked }));
Assert.DoesNotContain(rows, r => r.Status == AuditStatus.Delivered);
Assert.All(rows, r => Assert.Contains(r.AsRow().Status, new[] { AuditStatus.Failed, AuditStatus.Parked }));
Assert.DoesNotContain(rows, r => r.AsRow().Status == AuditStatus.Delivered);
}
[SkippableFact]
@@ -247,8 +257,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
Assert.All(rows, r => Assert.Contains(r.SourceSiteId, new[] { siteA, siteB }));
Assert.DoesNotContain(rows, r => r.SourceSiteId == siteC);
Assert.All(rows, r => Assert.Contains(r.AsRow().SourceSiteId, new[] { siteA, siteB }));
Assert.DoesNotContain(rows, r => r.AsRow().SourceSiteId == siteC);
}
[SkippableFact]
@@ -294,7 +304,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
Assert.All(rows, r => Assert.Equal(siteId, r.SourceSiteId));
Assert.All(rows, r => Assert.Equal(siteId, r.AsRow().SourceSiteId));
}
// ──────────────────────────────────────────────────────────────────────
@@ -407,7 +417,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
Assert.All(rows, r => Assert.Equal(executionId, r.ExecutionId));
Assert.All(rows, r => Assert.Equal(executionId, r.AsRow().ExecutionId));
}
[SkippableFact]
@@ -436,7 +446,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
Assert.All(rows, r => Assert.Equal(parentExecutionId, r.ParentExecutionId));
Assert.All(rows, r => Assert.Equal(parentExecutionId, r.AsRow().ParentExecutionId));
}
[SkippableFact]
@@ -494,7 +504,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(
PageSize: 2,
AfterOccurredAtUtc: cursor.OccurredAtUtc,
AfterOccurredAtUtc: cursor.AsRow().OccurredAtUtc,
AfterEventId: cursor.EventId));
Assert.Equal(2, page2.Count);
@@ -506,7 +516,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(
PageSize: 2,
AfterOccurredAtUtc: cursor2.OccurredAtUtc,
AfterOccurredAtUtc: cursor2.AsRow().OccurredAtUtc,
AfterEventId: cursor2.EventId));
Assert.Single(page3);
@@ -541,7 +551,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
});
await using var readContext = CreateContext();
var count = await readContext.Set<AuditEvent>()
var count = await readContext.Set<AuditLogRow>()
.Where(e => e.SourceSiteId == siteId)
.CountAsync();
@@ -587,7 +597,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
filter,
new AuditLogPaging(
PageSize: 2,
AfterOccurredAtUtc: cursor.OccurredAtUtc,
AfterOccurredAtUtc: cursor.AsRow().OccurredAtUtc,
AfterEventId: cursor.EventId));
Assert.Equal(2, page2.Count);
@@ -647,7 +657,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
await repo.SwitchOutPartitionAsync(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
await using var readContext = CreateContext();
var remaining = await readContext.Set<AuditEvent>()
var remaining = await readContext.Set<AuditLogRow>()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
@@ -697,11 +707,20 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
// (UX_AuditLog_EventId is the index that enables idempotency; if the
// rebuild left it broken, this insert would silently produce a duplicate
// row and the count assertion below would catch it).
var dup = preExisting with { ErrorMessage = "second-should-be-ignored-after-switch" };
// C3 (Task 2.5): rebuild a sibling row with the same EventId via the factory
// (ErrorMessage rides in DetailsJson, so a top-level `with` no longer applies).
var dup = ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.ApiOutbound,
kind: AuditKind.ApiCall,
status: AuditStatus.Delivered,
eventId: preExisting.EventId,
occurredAtUtc: new DateTime(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc),
sourceSiteId: siteId,
errorMessage: "second-should-be-ignored-after-switch");
await repo.InsertIfNotExistsAsync(dup);
await using var readContext = CreateContext();
var rows = await readContext.Set<AuditEvent>()
var rows = await readContext.Set<AuditLogRow>()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
@@ -1089,6 +1108,9 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
private static string NewSiteId() =>
"test-bundle-d-" + Guid.NewGuid().ToString("N").Substring(0, 8);
// C3 (Task 2.5): build the canonical ZB.MOM.WW.Audit.AuditEvent via the shared
// factory; the repository's transitional shim decomposes it into the 24-column
// AuditLogRow on INSERT and recomposes the canonical record on QUERY.
private static AuditEvent NewEvent(
string siteId,
DateTime occurredAtUtc,
@@ -1099,17 +1121,14 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Guid? executionId = null,
Guid? parentExecutionId = null,
string? sourceNode = null) =>
new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = occurredAtUtc,
Channel = channel,
Kind = kind,
Status = status,
SourceSiteId = siteId,
SourceNode = sourceNode,
ErrorMessage = errorMessage,
ExecutionId = executionId,
ParentExecutionId = parentExecutionId,
};
ScadaBridgeAuditEventFactory.Create(
channel: channel,
kind: kind,
status: status,
occurredAtUtc: occurredAtUtc,
sourceNode: sourceNode,
sourceSiteId: siteId,
executionId: executionId,
parentExecutionId: parentExecutionId,
errorMessage: errorMessage);
}