From 3d77dc003ca29332312829b5ae47f921c21b9aca Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 2 Jun 2026 10:42:51 -0400 Subject: [PATCH] =?UTF-8?q?feat(audit):=20ScadaBridge=20C1=20=E2=80=94=20A?= =?UTF-8?q?uditDetails=20codec=20(deterministic)=20+=20AuditOutcome=20proj?= =?UTF-8?q?ection=20+=20canonical=20field=20builders=20+=20ZB.MOM.WW.Audit?= =?UTF-8?q?=20ref=20(Task=202.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Types/Audit/AuditDetails.cs | 70 +++++++ .../Types/Audit/AuditDetailsCodec.cs | 72 +++++++ .../Types/Audit/AuditFieldBuilders.cs | 33 +++ .../Types/Audit/AuditOutcomeProjector.cs | 45 +++++ .../ZB.MOM.WW.ScadaBridge.Commons.csproj | 4 + .../Types/Audit/AuditDetailsCodecTests.cs | 188 ++++++++++++++++++ .../Types/Audit/AuditFieldBuildersTests.cs | 44 ++++ .../Types/Audit/AuditOutcomeProjectorTests.cs | 110 ++++++++++ 8 files changed, 566 insertions(+) create mode 100644 src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditDetails.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditDetailsCodec.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditFieldBuilders.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditOutcomeProjector.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/AuditDetailsCodecTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/AuditFieldBuildersTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/AuditOutcomeProjectorTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditDetails.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditDetails.cs new file mode 100644 index 00000000..0d703cf3 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditDetails.cs @@ -0,0 +1,70 @@ +namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; + +/// +/// ScadaBridge domain fields that travel inside ZB.MOM.WW.Audit.AuditEvent.DetailsJson. +/// All optional. Channel, Kind, and Status carry the string +/// representations of the corresponding ScadaBridge enums for stable JSON serialization. +/// ForwardState is deliberately absent — it remains a site-sidecar concern. +/// Serialized and deserialized by . +/// Property declaration order is load-bearing: relies on it for +/// byte-deterministic output (System.Text.Json respects declared order). +/// +/// +/// C1 of the ScadaBridge audit re-architecture (Task 2.5). Pure types only. +/// +public sealed record AuditDetails +{ + /// Top-level audit channel (AuditChannel.ToString()). + public string? Channel { get; init; } + + /// Specific event kind (AuditKind.ToString()). + public string? Kind { get; init; } + + /// Lifecycle status (AuditStatus.ToString()). + public string? Status { get; init; } + + /// Execution / operation identifier for the audited call. + public Guid? ExecutionId { get; init; } + + /// Parent execution identifier for nested / cached operations. + public Guid? ParentExecutionId { get; init; } + + /// Site identifier where the event originated. + public string? SourceSiteId { get; init; } + + /// Instance (Galaxy instance / target resource) identifier. + public string? SourceInstanceId { get; init; } + + /// Script name or endpoint that initiated the audited action. + public string? SourceScript { get; init; } + + /// HTTP status code returned by the outbound or inbound call. + public int? HttpStatus { get; init; } + + /// Elapsed duration of the audited action in milliseconds. + public int? DurationMs { get; init; } + + /// Short error message when the action failed. + public string? ErrorMessage { get; init; } + + /// Full error detail / stack excerpt when the action failed. + public string? ErrorDetail { get; init; } + + /// Condensed request representation (URL, method, or payload summary). + public string? RequestSummary { get; init; } + + /// Condensed response representation (status + body summary). + public string? ResponseSummary { get; init; } + + /// + /// true if any string field in this record was truncated before serialization. + /// Included in JSON even when false (non-nullable bool, never null-omitted). + /// + public bool PayloadTruncated { get; init; } + + /// Freeform extension JSON for fields not covered above. + public string? Extra { get; init; } + + /// UTC instant when the central AuditLog store ingested this event. + public DateTimeOffset? IngestedAtUtc { get; init; } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditDetailsCodec.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditDetailsCodec.cs new file mode 100644 index 00000000..a55a5458 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditDetailsCodec.cs @@ -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; + +/// +/// Serializes and deserializes to/from the JSON string that +/// occupies ZB.MOM.WW.Audit.AuditEvent.DetailsJson. +/// +/// +/// +/// The options are DETERMINISTIC by design — this is a load-bearing contract: +/// canonical AuditEvent uses value-equality (record) and consumers dedup on it, so +/// two independent emitters must produce byte-identical DetailsJson for equal inputs. +/// The options that make this deterministic: +/// +/// — stable camelCase key names. +/// WriteIndented = false — no whitespace variation. +/// — null fields absent; non-nullable +/// bool fields (like ) always present. +/// — angle brackets and other +/// extended characters (including <redacted> markers) are NOT percent-escaped, +/// preserving the exact byte value of any existing redaction strings. +/// Property declaration order on fixes key order — +/// System.Text.Json honours declared order, so serialization is stable across calls. +/// +/// +/// C1 of the ScadaBridge audit re-architecture (Task 2.5). +/// +public static class AuditDetailsCodec +{ + /// + /// Single, cached, immutable options instance. Re-using one instance avoids repeated + /// reflection overhead and guarantees the same encoder is used across all serialization calls. + /// + private static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + /// + /// Serializes to a compact, deterministic JSON string + /// suitable for storage in AuditEvent.DetailsJson. + /// + public static string Serialize(AuditDetails details) + => JsonSerializer.Serialize(details, Options); + + /// + /// Deserializes a from . + /// Returns an empty (all-null) when + /// is null, empty, or whitespace — never throws. + /// + public static AuditDetails Deserialize(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return new AuditDetails(); + + try + { + return JsonSerializer.Deserialize(json, Options) + ?? new AuditDetails(); + } + catch (JsonException) + { + return new AuditDetails(); + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditFieldBuilders.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditFieldBuilders.cs new file mode 100644 index 00000000..b6c6bc57 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditFieldBuilders.cs @@ -0,0 +1,33 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; + +/// +/// Builds the canonical Action and Category fields for +/// ZB.MOM.WW.Audit.AuditEvent from ScadaBridge's channel + kind enums. +/// +/// +/// +/// +/// produces "{channel}.{kind}" — a stable dot-separated +/// identifier that maps directly onto the canonical Action field. +/// produces channel.ToString() — a grouping token +/// that maps onto the canonical Category field. +/// +/// +/// C1 of the ScadaBridge audit re-architecture (Task 2.5). +/// +public static class AuditFieldBuilders +{ + /// + /// Returns the canonical Action string: "{channel}.{kind}". + /// + public static string BuildAction(AuditChannel channel, AuditKind kind) + => $"{channel}.{kind}"; + + /// + /// Returns the canonical Category string: the channel name. + /// + public static string BuildCategory(AuditChannel channel) + => channel.ToString(); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditOutcomeProjector.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditOutcomeProjector.cs new file mode 100644 index 00000000..4bbf193e --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditOutcomeProjector.cs @@ -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; + +/// +/// Maps the ScadaBridge (, ) pair onto +/// the canonical required by ZB.MOM.WW.Audit.AuditEvent. +/// +/// +/// Projection table: +/// +/// +/// (checked first, overrides any status). +/// . +/// , , +/// . +/// All other statuses (Submitted, Forwarded, Attempted, +/// Skipped) → . +/// +/// C1 of the ScadaBridge audit re-architecture (Task 2.5). +/// +public static class AuditOutcomeProjector +{ + /// + /// Projects + onto the canonical + /// . + /// + 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, + }; + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj b/src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj index 023297e2..55b3ee87 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj @@ -7,4 +7,8 @@ true + + + + diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/AuditDetailsCodecTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/AuditDetailsCodecTests.cs new file mode 100644 index 00000000..b059bdc4 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/AuditDetailsCodecTests.cs @@ -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; + +/// +/// 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. +/// +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 = "" }; + var json = AuditDetailsCodec.Serialize(details); + Assert.Contains("\"\"", json); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/AuditFieldBuildersTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/AuditFieldBuildersTests.cs new file mode 100644 index 00000000..90487b0c --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/AuditFieldBuildersTests.cs @@ -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; + +/// +/// C1 canonical field builder tests: BuildAction and BuildCategory. +/// +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)); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/AuditOutcomeProjectorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/AuditOutcomeProjectorTests.cs new file mode 100644 index 00000000..66666cd7 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/AuditOutcomeProjectorTests.cs @@ -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; + +/// +/// C1 projection tests: the full AuditStatus × AuditKind → AuditOutcome table, plus +/// the InboundAuthFailure-Denied precedence that must be checked before any status rule. +/// +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)); + } +}