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:
@@ -0,0 +1,70 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// ScadaBridge domain fields that travel inside <c>ZB.MOM.WW.Audit.AuditEvent.DetailsJson</c>.
|
||||
/// All optional. <c>Channel</c>, <c>Kind</c>, and <c>Status</c> carry the string
|
||||
/// representations of the corresponding ScadaBridge enums for stable JSON serialization.
|
||||
/// <c>ForwardState</c> is deliberately absent — it remains a site-sidecar concern.
|
||||
/// Serialized and deserialized by <see cref="AuditDetailsCodec"/>.
|
||||
/// Property declaration order is load-bearing: <see cref="AuditDetailsCodec"/> relies on it for
|
||||
/// byte-deterministic output (System.Text.Json respects declared order).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// C1 of the ScadaBridge audit re-architecture (Task 2.5). Pure types only.
|
||||
/// </remarks>
|
||||
public sealed record AuditDetails
|
||||
{
|
||||
/// <summary>Top-level audit channel (<c>AuditChannel.ToString()</c>).</summary>
|
||||
public string? Channel { get; init; }
|
||||
|
||||
/// <summary>Specific event kind (<c>AuditKind.ToString()</c>).</summary>
|
||||
public string? Kind { get; init; }
|
||||
|
||||
/// <summary>Lifecycle status (<c>AuditStatus.ToString()</c>).</summary>
|
||||
public string? Status { get; init; }
|
||||
|
||||
/// <summary>Execution / operation identifier for the audited call.</summary>
|
||||
public Guid? ExecutionId { get; init; }
|
||||
|
||||
/// <summary>Parent execution identifier for nested / cached operations.</summary>
|
||||
public Guid? ParentExecutionId { get; init; }
|
||||
|
||||
/// <summary>Site identifier where the event originated.</summary>
|
||||
public string? SourceSiteId { get; init; }
|
||||
|
||||
/// <summary>Instance (Galaxy instance / target resource) identifier.</summary>
|
||||
public string? SourceInstanceId { get; init; }
|
||||
|
||||
/// <summary>Script name or endpoint that initiated the audited action.</summary>
|
||||
public string? SourceScript { get; init; }
|
||||
|
||||
/// <summary>HTTP status code returned by the outbound or inbound call.</summary>
|
||||
public int? HttpStatus { get; init; }
|
||||
|
||||
/// <summary>Elapsed duration of the audited action in milliseconds.</summary>
|
||||
public int? DurationMs { get; init; }
|
||||
|
||||
/// <summary>Short error message when the action failed.</summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>Full error detail / stack excerpt when the action failed.</summary>
|
||||
public string? ErrorDetail { get; init; }
|
||||
|
||||
/// <summary>Condensed request representation (URL, method, or payload summary).</summary>
|
||||
public string? RequestSummary { get; init; }
|
||||
|
||||
/// <summary>Condensed response representation (status + body summary).</summary>
|
||||
public string? ResponseSummary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> if any string field in this record was truncated before serialization.
|
||||
/// Included in JSON even when <c>false</c> (non-nullable bool, never null-omitted).
|
||||
/// </summary>
|
||||
public bool PayloadTruncated { get; init; }
|
||||
|
||||
/// <summary>Freeform extension JSON for fields not covered above.</summary>
|
||||
public string? Extra { get; init; }
|
||||
|
||||
/// <summary>UTC instant when the central AuditLog store ingested this event.</summary>
|
||||
public DateTimeOffset? IngestedAtUtc { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Serializes and deserializes <see cref="AuditDetails"/> to/from the JSON string that
|
||||
/// occupies <c>ZB.MOM.WW.Audit.AuditEvent.DetailsJson</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The options are DETERMINISTIC by design — this is a load-bearing contract:
|
||||
/// canonical <c>AuditEvent</c> uses value-equality (record) and consumers dedup on it, so
|
||||
/// two independent emitters must produce byte-identical <c>DetailsJson</c> for equal inputs.
|
||||
/// The options that make this deterministic:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="JsonNamingPolicy.CamelCase"/> — stable camelCase key names.</item>
|
||||
/// <item><c>WriteIndented = false</c> — no whitespace variation.</item>
|
||||
/// <item><see cref="JsonIgnoreCondition.WhenWritingNull"/> — null fields absent; non-nullable
|
||||
/// <c>bool</c> fields (like <see cref="AuditDetails.PayloadTruncated"/>) always present.</item>
|
||||
/// <item><see cref="JavaScriptEncoder.UnsafeRelaxedJsonEscaping"/> — angle brackets and other
|
||||
/// extended characters (including <c><redacted></c> markers) are NOT percent-escaped,
|
||||
/// preserving the exact byte value of any existing redaction strings.</item>
|
||||
/// <item>Property declaration order on <see cref="AuditDetails"/> fixes key order —
|
||||
/// System.Text.Json honours declared order, so serialization is stable across calls.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>C1 of the ScadaBridge audit re-architecture (Task 2.5).</para>
|
||||
/// </remarks>
|
||||
public static class AuditDetailsCodec
|
||||
{
|
||||
/// <summary>
|
||||
/// Single, cached, immutable options instance. Re-using one instance avoids repeated
|
||||
/// reflection overhead and guarantees the same encoder is used across all serialization calls.
|
||||
/// </summary>
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Serializes <paramref name="details"/> to a compact, deterministic JSON string
|
||||
/// suitable for storage in <c>AuditEvent.DetailsJson</c>.
|
||||
/// </summary>
|
||||
public static string Serialize(AuditDetails details)
|
||||
=> JsonSerializer.Serialize(details, Options);
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes a <see cref="AuditDetails"/> from <paramref name="json"/>.
|
||||
/// Returns an empty (all-null) <see cref="AuditDetails"/> when <paramref name="json"/>
|
||||
/// is <c>null</c>, empty, or whitespace — never throws.
|
||||
/// </summary>
|
||||
public static AuditDetails Deserialize(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return new AuditDetails();
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<AuditDetails>(json, Options)
|
||||
?? new AuditDetails();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new AuditDetails();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the canonical <c>Action</c> and <c>Category</c> fields for
|
||||
/// <c>ZB.MOM.WW.Audit.AuditEvent</c> from ScadaBridge's channel + kind enums.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="BuildAction"/> produces <c>"{channel}.{kind}"</c> — a stable dot-separated
|
||||
/// identifier that maps directly onto the canonical <c>Action</c> field.</item>
|
||||
/// <item><see cref="BuildCategory"/> produces <c>channel.ToString()</c> — a grouping token
|
||||
/// that maps onto the canonical <c>Category</c> field.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>C1 of the ScadaBridge audit re-architecture (Task 2.5).</para>
|
||||
/// </remarks>
|
||||
public static class AuditFieldBuilders
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the canonical <c>Action</c> string: <c>"{channel}.{kind}"</c>.
|
||||
/// </summary>
|
||||
public static string BuildAction(AuditChannel channel, AuditKind kind)
|
||||
=> $"{channel}.{kind}";
|
||||
|
||||
/// <summary>
|
||||
/// Returns the canonical <c>Category</c> string: the channel name.
|
||||
/// </summary>
|
||||
public static string BuildCategory(AuditChannel channel)
|
||||
=> channel.ToString();
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Maps the ScadaBridge (<see cref="AuditStatus"/>, <see cref="AuditKind"/>) pair onto
|
||||
/// the canonical <see cref="AuditOutcome"/> required by <c>ZB.MOM.WW.Audit.AuditEvent</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Projection table:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="AuditKind.InboundAuthFailure"/> → <see cref="AuditOutcome.Denied"/>
|
||||
/// (checked <b>first</b>, overrides any status).</item>
|
||||
/// <item><see cref="AuditStatus.Delivered"/> → <see cref="AuditOutcome.Success"/>.</item>
|
||||
/// <item><see cref="AuditStatus.Failed"/>, <see cref="AuditStatus.Parked"/>,
|
||||
/// <see cref="AuditStatus.Discarded"/> → <see cref="AuditOutcome.Failure"/>.</item>
|
||||
/// <item>All other statuses (<c>Submitted</c>, <c>Forwarded</c>, <c>Attempted</c>,
|
||||
/// <c>Skipped</c>) → <see cref="AuditOutcome.Success"/>.</item>
|
||||
/// </list>
|
||||
/// <para>C1 of the ScadaBridge audit re-architecture (Task 2.5).</para>
|
||||
/// </remarks>
|
||||
public static class AuditOutcomeProjector
|
||||
{
|
||||
/// <summary>
|
||||
/// Projects <paramref name="status"/> + <paramref name="kind"/> onto the canonical
|
||||
/// <see cref="AuditOutcome"/>.
|
||||
/// </summary>
|
||||
public static AuditOutcome Project(AuditStatus status, AuditKind kind)
|
||||
{
|
||||
// Auth-failure kind takes absolute precedence — checked before any status rule.
|
||||
if (kind == AuditKind.InboundAuthFailure)
|
||||
return AuditOutcome.Denied;
|
||||
|
||||
return status switch
|
||||
{
|
||||
AuditStatus.Delivered => AuditOutcome.Success,
|
||||
AuditStatus.Failed => AuditOutcome.Failure,
|
||||
AuditStatus.Parked => AuditOutcome.Failure,
|
||||
AuditStatus.Discarded => AuditOutcome.Failure,
|
||||
// Submitted / Forwarded / Attempted / Skipped → in-progress or short-circuit → Success
|
||||
_ => AuditOutcome.Success,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,8 @@
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ZB.MOM.WW.Audit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user