diff --git a/src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs b/src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs new file mode 100644 index 0000000..f5148a3 --- /dev/null +++ b/src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs @@ -0,0 +1,73 @@ +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.Commons.Entities.Audit; + +/// +/// Single source of truth for AuditLog (#23) rows. Central rows leave ForwardState null; +/// site rows leave IngestedAtUtc null until ingest. Append-only. +/// +public sealed record AuditEvent +{ + /// Idempotency key; uniquely identifies one audit lifecycle event. + public Guid EventId { get; init; } + + /// UTC timestamp when the audited action occurred at its source. + public DateTime OccurredAtUtc { get; init; } + + /// UTC timestamp when the row was ingested at central; null on the site hot-path. + public DateTime? IngestedAtUtc { get; init; } + + /// Trust-boundary channel the audited action crossed. + public AuditChannel Channel { get; init; } + + /// Specific event kind within the channel (see alog.md ยง4). + public AuditKind Kind { get; init; } + + /// Correlation id linking related audit rows (e.g. the cached-op lifecycle). + public Guid? CorrelationId { get; init; } + + /// Site id where the action originated; null for central-direct events. + public string? SourceSiteId { get; init; } + + /// Instance id where the action originated, when applicable. + public string? SourceInstanceId { get; init; } + + /// Script that initiated the action (script trust boundary), when applicable. + public string? SourceScript { get; init; } + + /// Authenticated actor for inbound paths (API key name, user, etc.). + public string? Actor { get; init; } + + /// Target of the action: external system name, db connection name, list name, or inbound method. + public string? Target { get; init; } + + /// Lifecycle status of this row. + public AuditStatus Status { get; init; } + + /// HTTP status code where applicable (outbound API + inbound API). + public int? HttpStatus { get; init; } + + /// Duration of the audited action in milliseconds, when measurable. + public int? DurationMs { get; init; } + + /// Human-readable error summary on failure rows. + public string? ErrorMessage { get; init; } + + /// Verbose error detail (stack/exception) on failure rows. + public string? ErrorDetail { get; init; } + + /// Truncated/redacted request summary; capped per AuditLogOptions. + public string? RequestSummary { get; init; } + + /// Truncated/redacted response summary; capped per AuditLogOptions. + public string? ResponseSummary { get; init; } + + /// True when Request/Response summaries were truncated to the payload cap. + public bool PayloadTruncated { get; init; } + + /// Free-form JSON extension column for channel-specific extras. + public string? Extra { get; init; } + + /// Site-local forwarding state; null on central rows. + public AuditForwardState? ForwardState { get; init; } +} diff --git a/tests/ScadaLink.Commons.Tests/Entities/Audit/AuditEventTests.cs b/tests/ScadaLink.Commons.Tests/Entities/Audit/AuditEventTests.cs new file mode 100644 index 0000000..37ccb9f --- /dev/null +++ b/tests/ScadaLink.Commons.Tests/Entities/Audit/AuditEventTests.cs @@ -0,0 +1,135 @@ +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.Commons.Tests.Entities.Audit; + +/// +/// Verifies behaves as an init-only record: +/// every property reads back as constructed, and with expressions +/// produce a new instance with a single property changed. +/// +public class AuditEventTests +{ + [Fact] + public void Construction_AllPropertiesReadBack() + { + var eventId = Guid.NewGuid(); + var occurredAt = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc); + var ingestedAt = new DateTime(2026, 5, 20, 12, 0, 1, DateTimeKind.Utc); + var corrId = Guid.NewGuid(); + + var evt = new AuditEvent + { + EventId = eventId, + OccurredAtUtc = occurredAt, + IngestedAtUtc = ingestedAt, + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCall, + CorrelationId = corrId, + SourceSiteId = "site-01", + SourceInstanceId = "inst-7", + SourceScript = "OnAlarm", + Actor = "system", + Target = "WeatherAPI", + Status = AuditStatus.Delivered, + HttpStatus = 200, + DurationMs = 42, + ErrorMessage = null, + ErrorDetail = null, + RequestSummary = "GET /forecast", + ResponseSummary = "{\"temp\":21}", + PayloadTruncated = false, + Extra = "{}", + ForwardState = AuditForwardState.Forwarded + }; + + Assert.Equal(eventId, evt.EventId); + Assert.Equal(occurredAt, evt.OccurredAtUtc); + Assert.Equal(ingestedAt, evt.IngestedAtUtc); + Assert.Equal(AuditChannel.ApiOutbound, evt.Channel); + Assert.Equal(AuditKind.ApiCall, evt.Kind); + Assert.Equal(corrId, evt.CorrelationId); + Assert.Equal("site-01", evt.SourceSiteId); + Assert.Equal("inst-7", evt.SourceInstanceId); + Assert.Equal("OnAlarm", evt.SourceScript); + Assert.Equal("system", evt.Actor); + Assert.Equal("WeatherAPI", evt.Target); + Assert.Equal(AuditStatus.Delivered, evt.Status); + Assert.Equal(200, evt.HttpStatus); + Assert.Equal(42, evt.DurationMs); + Assert.Null(evt.ErrorMessage); + Assert.Null(evt.ErrorDetail); + Assert.Equal("GET /forecast", evt.RequestSummary); + Assert.Equal("{\"temp\":21}", evt.ResponseSummary); + Assert.False(evt.PayloadTruncated); + Assert.Equal("{}", evt.Extra); + Assert.Equal(AuditForwardState.Forwarded, evt.ForwardState); + } + + [Fact] + public void NullableProperties_AcceptNull() + { + var evt = new AuditEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = DateTime.UtcNow, + IngestedAtUtc = null, + Channel = AuditChannel.Notification, + Kind = AuditKind.NotifySend, + 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 + }; + + Assert.Null(evt.IngestedAtUtc); + Assert.Null(evt.CorrelationId); + Assert.Null(evt.SourceSiteId); + Assert.Null(evt.SourceInstanceId); + Assert.Null(evt.SourceScript); + Assert.Null(evt.Actor); + Assert.Null(evt.Target); + Assert.Null(evt.HttpStatus); + Assert.Null(evt.DurationMs); + Assert.Null(evt.ErrorMessage); + Assert.Null(evt.ErrorDetail); + Assert.Null(evt.RequestSummary); + Assert.Null(evt.ResponseSummary); + Assert.Null(evt.Extra); + Assert.Null(evt.ForwardState); + } + + [Fact] + public void With_ProducesNewInstance_WithSingleFieldChanged() + { + var original = new AuditEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = DateTime.UtcNow, + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCall, + Status = AuditStatus.Submitted, + PayloadTruncated = false + }; + + var updated = original with { Status = AuditStatus.Delivered }; + + Assert.NotSame(original, updated); + Assert.Equal(AuditStatus.Submitted, original.Status); + Assert.Equal(AuditStatus.Delivered, updated.Status); + Assert.Equal(original.EventId, updated.EventId); + Assert.NotEqual(original, updated); + } +}