feat(audit): ScadaBridge C1 — AuditDetails codec (deterministic) + AuditOutcome projection + canonical field builders + ZB.MOM.WW.Audit ref (Task 2.5)

Additive foundation only — no existing type/interface/emitter changed.
Commons now references ZB.MOM.WW.Audit 0.1.0 (Gitea feed, central PM pin).
Adds four pure new types in Commons/Types/Audit/:
  AuditDetails (sealed record, 17 domain fields, declaration-order = JSON key order)
  AuditDetailsCodec (static; single cached JsonSerializerOptions: camelCase, no-indent,
    WhenWritingNull, UnsafeRelaxedJsonEscaping — byte-deterministic across calls)
  AuditOutcomeProjector (static; InboundAuthFailure→Denied first, then Delivered→Success,
    Failed/Parked/Discarded→Failure, all others→Success)
  AuditFieldBuilders (static; BuildAction="{channel}.{kind}", BuildCategory=channel.ToString())
56 new tests in Commons.Tests/Types/Audit/ covering codec round-trip, byte-determinism
(hand-pinned expected JSON string), null/empty sentinel, full projection table,
InboundAuthFailure-Denied precedence, and Action/Category builders. All pass.
This commit is contained in:
Joseph Doherty
2026-06-02 10:42:51 -04:00
parent 4118452e72
commit 3d77dc003c
8 changed files with 566 additions and 0 deletions
@@ -0,0 +1,188 @@
using System.Text;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Audit;
/// <summary>
/// C1 codec tests: round-trip, byte-determinism, and null/empty sentinel.
/// Determinism is load-bearing — consumers dedup on canonical AuditEvent value-equality
/// which includes DetailsJson, so two emitters must produce identical bytes for equal inputs.
/// </summary>
public class AuditDetailsCodecTests
{
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
private static AuditDetails FullDetails() => new()
{
Channel = AuditChannel.ApiOutbound.ToString(),
Kind = AuditKind.ApiCall.ToString(),
Status = AuditStatus.Delivered.ToString(),
ExecutionId = new Guid("11111111-1111-1111-1111-111111111111"),
ParentExecutionId = new Guid("22222222-2222-2222-2222-222222222222"),
SourceSiteId = "site-a",
SourceInstanceId = "inst-001",
SourceScript = "MyScript",
HttpStatus = 200,
DurationMs = 42,
ErrorMessage = null,
ErrorDetail = null,
RequestSummary = "GET /api/data",
ResponseSummary = "{\"ok\":true}",
PayloadTruncated = false,
Extra = null,
IngestedAtUtc = new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero),
};
// ---------------------------------------------------------------------------
// Round-trip: all non-null fields survive
// ---------------------------------------------------------------------------
[Fact]
public void RoundTrip_AllFields_Populated()
{
var original = FullDetails();
var json = AuditDetailsCodec.Serialize(original);
var restored = AuditDetailsCodec.Deserialize(json);
Assert.Equal(original.Channel, restored.Channel);
Assert.Equal(original.Kind, restored.Kind);
Assert.Equal(original.Status, restored.Status);
Assert.Equal(original.ExecutionId, restored.ExecutionId);
Assert.Equal(original.ParentExecutionId, restored.ParentExecutionId);
Assert.Equal(original.SourceSiteId, restored.SourceSiteId);
Assert.Equal(original.SourceInstanceId, restored.SourceInstanceId);
Assert.Equal(original.SourceScript, restored.SourceScript);
Assert.Equal(original.HttpStatus, restored.HttpStatus);
Assert.Equal(original.DurationMs, restored.DurationMs);
Assert.Equal(original.RequestSummary, restored.RequestSummary);
Assert.Equal(original.ResponseSummary, restored.ResponseSummary);
Assert.Equal(original.PayloadTruncated, restored.PayloadTruncated);
Assert.Equal(original.IngestedAtUtc, restored.IngestedAtUtc);
}
// ---------------------------------------------------------------------------
// Round-trip: all-null record
// ---------------------------------------------------------------------------
[Fact]
public void RoundTrip_AllNullOptionals_ReturnsEmptyDetails()
{
var empty = new AuditDetails();
var json = AuditDetailsCodec.Serialize(empty);
var restored = AuditDetailsCodec.Deserialize(json);
Assert.Null(restored.Channel);
Assert.Null(restored.Kind);
Assert.Null(restored.Status);
Assert.Null(restored.ExecutionId);
Assert.Null(restored.ParentExecutionId);
Assert.Null(restored.SourceSiteId);
Assert.Null(restored.SourceInstanceId);
Assert.Null(restored.SourceScript);
Assert.Null(restored.HttpStatus);
Assert.Null(restored.DurationMs);
Assert.Null(restored.ErrorMessage);
Assert.Null(restored.ErrorDetail);
Assert.Null(restored.RequestSummary);
Assert.Null(restored.ResponseSummary);
Assert.False(restored.PayloadTruncated);
Assert.Null(restored.Extra);
Assert.Null(restored.IngestedAtUtc);
}
// ---------------------------------------------------------------------------
// Null / empty JSON deserialize → empty AuditDetails (no throw)
// ---------------------------------------------------------------------------
[Fact]
public void Deserialize_Null_ReturnsEmpty()
{
var d = AuditDetailsCodec.Deserialize(null);
Assert.NotNull(d);
Assert.Null(d.Channel);
}
[Fact]
public void Deserialize_EmptyString_ReturnsEmpty()
{
var d = AuditDetailsCodec.Deserialize(string.Empty);
Assert.NotNull(d);
Assert.Null(d.Channel);
}
// ---------------------------------------------------------------------------
// Determinism: same input → byte-identical JSON across two calls
// ---------------------------------------------------------------------------
[Fact]
public void Serialize_SameInput_ProducesByteIdenticalOutput_AcrossCalls()
{
var details = FullDetails();
var json1 = AuditDetailsCodec.Serialize(details);
var json2 = AuditDetailsCodec.Serialize(details);
Assert.Equal(json1, json2);
Assert.Equal(
Encoding.UTF8.GetBytes(json1),
Encoding.UTF8.GetBytes(json2));
}
// ---------------------------------------------------------------------------
// Determinism: exact expected JSON string (camelCase key order = declaration order,
// null fields omitted, PayloadTruncated=false present because it is non-nullable bool)
// ---------------------------------------------------------------------------
[Fact]
public void Serialize_KnownInput_MatchesExpectedJson()
{
// Build a minimal deterministic fixture (only non-null fields we can pin exactly).
var details = new AuditDetails
{
Channel = "ApiOutbound",
Kind = "ApiCall",
Status = "Delivered",
ExecutionId = new Guid("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
SourceSiteId = "site-x",
HttpStatus = 200,
DurationMs = 10,
RequestSummary = "GET /ping",
ResponseSummary = "ok",
PayloadTruncated = false,
IngestedAtUtc = new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero),
};
// Expected: camelCase, no indent, nulls omitted, PayloadTruncated present (false is default for bool),
// key order follows property declaration order on AuditDetails.
// ingestedAtUtc serializes as ISO-8601 with offset.
const string expected =
"{\"channel\":\"ApiOutbound\"," +
"\"kind\":\"ApiCall\"," +
"\"status\":\"Delivered\"," +
"\"executionId\":\"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\"," +
"\"sourceSiteId\":\"site-x\"," +
"\"httpStatus\":200," +
"\"durationMs\":10," +
"\"requestSummary\":\"GET /ping\"," +
"\"responseSummary\":\"ok\"," +
"\"payloadTruncated\":false," +
"\"ingestedAtUtc\":\"2026-01-02T03:04:05+00:00\"}";
var actual = AuditDetailsCodec.Serialize(details);
Assert.Equal(expected, actual);
}
// ---------------------------------------------------------------------------
// Redaction marker preserved verbatim (UnsafeRelaxedJsonEscaping must not mangle <>)
// ---------------------------------------------------------------------------
[Fact]
public void Serialize_RedactionMarker_IsPreservedVerbatim()
{
var details = new AuditDetails { RequestSummary = "<redacted>" };
var json = AuditDetailsCodec.Serialize(details);
Assert.Contains("\"<redacted>\"", json);
}
}
@@ -0,0 +1,44 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Audit;
/// <summary>
/// C1 canonical field builder tests: BuildAction and BuildCategory.
/// </summary>
public class AuditFieldBuildersTests
{
// ---------------------------------------------------------------------------
// BuildAction: "{channel}.{kind}"
// ---------------------------------------------------------------------------
[Theory]
[InlineData(AuditChannel.ApiOutbound, AuditKind.ApiCall, "ApiOutbound.ApiCall")]
[InlineData(AuditChannel.ApiOutbound, AuditKind.ApiCallCached, "ApiOutbound.ApiCallCached")]
[InlineData(AuditChannel.DbOutbound, AuditKind.DbWrite, "DbOutbound.DbWrite")]
[InlineData(AuditChannel.DbOutbound, AuditKind.DbWriteCached, "DbOutbound.DbWriteCached")]
[InlineData(AuditChannel.Notification, AuditKind.NotifySend, "Notification.NotifySend")]
[InlineData(AuditChannel.Notification, AuditKind.NotifyDeliver, "Notification.NotifyDeliver")]
[InlineData(AuditChannel.ApiInbound, AuditKind.InboundRequest, "ApiInbound.InboundRequest")]
[InlineData(AuditChannel.ApiInbound, AuditKind.InboundAuthFailure, "ApiInbound.InboundAuthFailure")]
[InlineData(AuditChannel.ApiOutbound, AuditKind.CachedSubmit, "ApiOutbound.CachedSubmit")]
[InlineData(AuditChannel.ApiOutbound, AuditKind.CachedResolve, "ApiOutbound.CachedResolve")]
public void BuildAction_ReturnsChannelDotKind(AuditChannel channel, AuditKind kind, string expected)
{
Assert.Equal(expected, AuditFieldBuilders.BuildAction(channel, kind));
}
// ---------------------------------------------------------------------------
// BuildCategory: channel.ToString()
// ---------------------------------------------------------------------------
[Theory]
[InlineData(AuditChannel.ApiOutbound, "ApiOutbound")]
[InlineData(AuditChannel.DbOutbound, "DbOutbound")]
[InlineData(AuditChannel.Notification, "Notification")]
[InlineData(AuditChannel.ApiInbound, "ApiInbound")]
public void BuildCategory_ReturnsChannelName(AuditChannel channel, string expected)
{
Assert.Equal(expected, AuditFieldBuilders.BuildCategory(channel));
}
}
@@ -0,0 +1,110 @@
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.Commons.Tests.Types.Audit;
/// <summary>
/// C1 projection tests: the full AuditStatus × AuditKind → AuditOutcome table, plus
/// the InboundAuthFailure-Denied precedence that must be checked before any status rule.
/// </summary>
public class AuditOutcomeProjectorTests
{
// ---------------------------------------------------------------------------
// InboundAuthFailure → Denied, regardless of status (checked FIRST)
// ---------------------------------------------------------------------------
[Theory]
[InlineData(AuditStatus.Submitted)]
[InlineData(AuditStatus.Forwarded)]
[InlineData(AuditStatus.Attempted)]
[InlineData(AuditStatus.Delivered)]
[InlineData(AuditStatus.Failed)]
[InlineData(AuditStatus.Parked)]
[InlineData(AuditStatus.Discarded)]
[InlineData(AuditStatus.Skipped)]
public void InboundAuthFailure_AlwaysDenied_RegardlessOfStatus(AuditStatus status)
{
var outcome = AuditOutcomeProjector.Project(status, AuditKind.InboundAuthFailure);
Assert.Equal(AuditOutcome.Denied, outcome);
}
// ---------------------------------------------------------------------------
// Delivered → Success (for all non-auth-failure kinds)
// ---------------------------------------------------------------------------
[Theory]
[InlineData(AuditKind.ApiCall)]
[InlineData(AuditKind.ApiCallCached)]
[InlineData(AuditKind.DbWrite)]
[InlineData(AuditKind.DbWriteCached)]
[InlineData(AuditKind.NotifySend)]
[InlineData(AuditKind.NotifyDeliver)]
[InlineData(AuditKind.InboundRequest)]
[InlineData(AuditKind.CachedSubmit)]
[InlineData(AuditKind.CachedResolve)]
public void Delivered_Success_ForNonAuthFailureKinds(AuditKind kind)
{
var outcome = AuditOutcomeProjector.Project(AuditStatus.Delivered, kind);
Assert.Equal(AuditOutcome.Success, outcome);
}
// ---------------------------------------------------------------------------
// Failure statuses → Failure outcome
// ---------------------------------------------------------------------------
[Theory]
[InlineData(AuditStatus.Failed)]
[InlineData(AuditStatus.Parked)]
[InlineData(AuditStatus.Discarded)]
public void FailureStatuses_MapToFailureOutcome(AuditStatus status)
{
// Use a representative non-auth-failure kind
var outcome = AuditOutcomeProjector.Project(status, AuditKind.ApiCall);
Assert.Equal(AuditOutcome.Failure, outcome);
}
// InboundAuthFailure still wins over failure statuses
[Theory]
[InlineData(AuditStatus.Failed)]
[InlineData(AuditStatus.Parked)]
[InlineData(AuditStatus.Discarded)]
public void InboundAuthFailure_OverridesFailureStatuses(AuditStatus status)
{
var outcome = AuditOutcomeProjector.Project(status, AuditKind.InboundAuthFailure);
Assert.Equal(AuditOutcome.Denied, outcome);
}
// ---------------------------------------------------------------------------
// In-progress / short-circuit statuses → Success
// ---------------------------------------------------------------------------
[Theory]
[InlineData(AuditStatus.Submitted)]
[InlineData(AuditStatus.Forwarded)]
[InlineData(AuditStatus.Attempted)]
[InlineData(AuditStatus.Skipped)]
public void InProgressStatuses_MapToSuccess(AuditStatus status)
{
var outcome = AuditOutcomeProjector.Project(status, AuditKind.ApiCall);
Assert.Equal(AuditOutcome.Success, outcome);
}
// ---------------------------------------------------------------------------
// Full status × non-auth-failure kind cross-product (spot-check each status once more)
// ---------------------------------------------------------------------------
[Theory]
[InlineData(AuditStatus.Submitted, AuditKind.DbWrite, AuditOutcome.Success)]
[InlineData(AuditStatus.Forwarded, AuditKind.CachedSubmit, AuditOutcome.Success)]
[InlineData(AuditStatus.Attempted, AuditKind.NotifySend, AuditOutcome.Success)]
[InlineData(AuditStatus.Delivered, AuditKind.NotifyDeliver, AuditOutcome.Success)]
[InlineData(AuditStatus.Failed, AuditKind.DbWriteCached, AuditOutcome.Failure)]
[InlineData(AuditStatus.Parked, AuditKind.NotifySend, AuditOutcome.Failure)]
[InlineData(AuditStatus.Discarded, AuditKind.CachedResolve, AuditOutcome.Failure)]
[InlineData(AuditStatus.Skipped, AuditKind.InboundRequest, AuditOutcome.Success)]
public void ProjectionTable_MatchesSpec(AuditStatus status, AuditKind kind, AuditOutcome expected)
{
Assert.Equal(expected, AuditOutcomeProjector.Project(status, kind));
}
}