diff --git a/src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj b/src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj index 91c296c..821fb5b 100644 --- a/src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj +++ b/src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj @@ -21,6 +21,8 @@ IAuditLogRepository is registered by ScadaLink.ConfigurationDatabase; the project reference is documented here so M2 writers + telemetry actors can depend on it. --> + + diff --git a/src/ScadaLink.AuditLog/Telemetry/AuditEventMapper.cs b/src/ScadaLink.AuditLog/Telemetry/AuditEventMapper.cs new file mode 100644 index 0000000..d821db0 --- /dev/null +++ b/src/ScadaLink.AuditLog/Telemetry/AuditEventMapper.cs @@ -0,0 +1,112 @@ +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Types.Enums; +using ScadaLink.Communication.Grpc; +using Timestamp = Google.Protobuf.WellKnownTypes.Timestamp; + +namespace ScadaLink.AuditLog.Telemetry; + +/// +/// Bridges Audit Log (#23) rows between the in-process record +/// and the wire-format exchanged over the +/// IngestAuditEvents RPC. +/// +/// +/// Lossy by design: the proto contract intentionally omits two fields. +/// +/// — site-local SQLite state, never travels. +/// — central-set at ingest time, not at the site. +/// +/// +/// String nullability convention: proto3 scalar strings cannot be absent, so nullable +/// .NET strings round-trip as empty strings on the wire. Nullable integers use the +/// Int32Value wrapper so they preserve true null semantics. +/// +/// +public static class AuditEventMapper +{ + /// + /// Projects an into its wire-format DTO. Null reference + /// fields collapse to empty strings; null integer fields leave the wrapper unset. + /// + public static AuditEventDto ToDto(AuditEvent evt) + { + ArgumentNullException.ThrowIfNull(evt); + + var dto = new AuditEventDto + { + EventId = evt.EventId.ToString(), + OccurredAtUtc = Timestamp.FromDateTime(EnsureUtc(evt.OccurredAtUtc)), + Channel = evt.Channel.ToString(), + Kind = evt.Kind.ToString(), + CorrelationId = evt.CorrelationId?.ToString() ?? string.Empty, + SourceSiteId = evt.SourceSiteId ?? string.Empty, + SourceInstanceId = evt.SourceInstanceId ?? string.Empty, + SourceScript = evt.SourceScript ?? string.Empty, + Actor = evt.Actor ?? string.Empty, + Target = evt.Target ?? string.Empty, + Status = evt.Status.ToString(), + ErrorMessage = evt.ErrorMessage ?? string.Empty, + ErrorDetail = evt.ErrorDetail ?? string.Empty, + RequestSummary = evt.RequestSummary ?? string.Empty, + ResponseSummary = evt.ResponseSummary ?? string.Empty, + PayloadTruncated = evt.PayloadTruncated, + Extra = evt.Extra ?? string.Empty + }; + + if (evt.HttpStatus.HasValue) + { + dto.HttpStatus = evt.HttpStatus.Value; + } + + if (evt.DurationMs.HasValue) + { + dto.DurationMs = evt.DurationMs.Value; + } + + return dto; + } + + /// + /// Reconstructs an from its wire-format DTO. Empty strings + /// rehydrate as null reference values; absent integer wrappers stay null. + /// and + /// are intentionally left null — the central ingest actor sets the latter. + /// + public static AuditEvent FromDto(AuditEventDto dto) + { + ArgumentNullException.ThrowIfNull(dto); + + return new AuditEvent + { + EventId = Guid.Parse(dto.EventId), + OccurredAtUtc = DateTime.SpecifyKind(dto.OccurredAtUtc.ToDateTime(), DateTimeKind.Utc), + IngestedAtUtc = null, + Channel = Enum.Parse(dto.Channel), + Kind = Enum.Parse(dto.Kind), + CorrelationId = NullIfEmpty(dto.CorrelationId) is { } cid ? Guid.Parse(cid) : null, + SourceSiteId = NullIfEmpty(dto.SourceSiteId), + SourceInstanceId = NullIfEmpty(dto.SourceInstanceId), + SourceScript = NullIfEmpty(dto.SourceScript), + Actor = NullIfEmpty(dto.Actor), + Target = NullIfEmpty(dto.Target), + Status = Enum.Parse(dto.Status), + HttpStatus = dto.HttpStatus, + DurationMs = dto.DurationMs, + ErrorMessage = NullIfEmpty(dto.ErrorMessage), + ErrorDetail = NullIfEmpty(dto.ErrorDetail), + RequestSummary = NullIfEmpty(dto.RequestSummary), + ResponseSummary = NullIfEmpty(dto.ResponseSummary), + PayloadTruncated = dto.PayloadTruncated, + Extra = NullIfEmpty(dto.Extra), + ForwardState = null + }; + } + + private static string? NullIfEmpty(string? value) => + string.IsNullOrEmpty(value) ? null : value; + + private static DateTime EnsureUtc(DateTime value) => + value.Kind == DateTimeKind.Utc + ? value + : DateTime.SpecifyKind(value.ToUniversalTime(), DateTimeKind.Utc); +} diff --git a/tests/ScadaLink.AuditLog.Tests/Telemetry/AuditEventMapperTests.cs b/tests/ScadaLink.AuditLog.Tests/Telemetry/AuditEventMapperTests.cs new file mode 100644 index 0000000..6901361 --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/Telemetry/AuditEventMapperTests.cs @@ -0,0 +1,224 @@ +using Google.Protobuf.WellKnownTypes; +using ScadaLink.AuditLog.Telemetry; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Types.Enums; +using ScadaLink.Communication.Grpc; + +namespace ScadaLink.AuditLog.Tests.Telemetry; + +/// +/// Round-trip + edge tests for the that bridges +/// (Commons) ↔ (proto). +/// ForwardState is site-local and IngestedAtUtc is central-set, so neither survives +/// the proto round-trip. +/// +public class AuditEventMapperTests +{ + [Fact] + public void ToDto_FromDto_Roundtrip_FullyPopulated_PreservesAllFields() + { + var occurredAt = new DateTime(2026, 5, 20, 10, 15, 30, 123, DateTimeKind.Utc); + var ingestedAt = new DateTime(2026, 5, 20, 10, 15, 31, 0, DateTimeKind.Utc); + var correlationId = Guid.NewGuid(); + var eventId = Guid.NewGuid(); + + var original = new AuditEvent + { + EventId = eventId, + OccurredAtUtc = occurredAt, + IngestedAtUtc = ingestedAt, + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCallCached, + CorrelationId = correlationId, + SourceSiteId = "site-1", + SourceInstanceId = "Pump01", + SourceScript = "OnDemand", + Actor = "design-key", + Target = "weather-api", + Status = AuditStatus.Forwarded, + HttpStatus = 200, + DurationMs = 42, + ErrorMessage = "transient timeout", + ErrorDetail = "stack-trace", + RequestSummary = "GET /weather", + ResponseSummary = "{ \"ok\": true }", + PayloadTruncated = true, + Extra = "{ \"retryCount\": 1 }", + ForwardState = AuditForwardState.Pending + }; + + var dto = AuditEventMapper.ToDto(original); + var roundTripped = AuditEventMapper.FromDto(dto); + + Assert.Equal(original.EventId, roundTripped.EventId); + Assert.Equal(original.OccurredAtUtc, roundTripped.OccurredAtUtc); + Assert.Equal(original.Channel, roundTripped.Channel); + Assert.Equal(original.Kind, roundTripped.Kind); + Assert.Equal(original.CorrelationId, roundTripped.CorrelationId); + Assert.Equal(original.SourceSiteId, roundTripped.SourceSiteId); + Assert.Equal(original.SourceInstanceId, roundTripped.SourceInstanceId); + Assert.Equal(original.SourceScript, roundTripped.SourceScript); + Assert.Equal(original.Actor, roundTripped.Actor); + Assert.Equal(original.Target, roundTripped.Target); + Assert.Equal(original.Status, roundTripped.Status); + Assert.Equal(original.HttpStatus, roundTripped.HttpStatus); + Assert.Equal(original.DurationMs, roundTripped.DurationMs); + Assert.Equal(original.ErrorMessage, roundTripped.ErrorMessage); + Assert.Equal(original.ErrorDetail, roundTripped.ErrorDetail); + Assert.Equal(original.RequestSummary, roundTripped.RequestSummary); + Assert.Equal(original.ResponseSummary, roundTripped.ResponseSummary); + Assert.Equal(original.PayloadTruncated, roundTripped.PayloadTruncated); + Assert.Equal(original.Extra, roundTripped.Extra); + + // ForwardState + IngestedAtUtc are NOT carried in the proto contract. + Assert.Null(roundTripped.ForwardState); + Assert.Null(roundTripped.IngestedAtUtc); + } + + [Fact] + public void ToDto_NullableStringFields_BecomeEmptyString() + { + var evt = new AuditEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = DateTime.UtcNow, + Channel = AuditChannel.Notification, + Kind = AuditKind.NotifySend, + Status = AuditStatus.Submitted + // all string? fields left null; CorrelationId null + }; + + var dto = AuditEventMapper.ToDto(evt); + + Assert.Equal(string.Empty, dto.CorrelationId); + Assert.Equal(string.Empty, dto.SourceSiteId); + Assert.Equal(string.Empty, dto.SourceInstanceId); + Assert.Equal(string.Empty, dto.SourceScript); + Assert.Equal(string.Empty, dto.Actor); + Assert.Equal(string.Empty, dto.Target); + Assert.Equal(string.Empty, dto.ErrorMessage); + Assert.Equal(string.Empty, dto.ErrorDetail); + Assert.Equal(string.Empty, dto.RequestSummary); + Assert.Equal(string.Empty, dto.ResponseSummary); + Assert.Equal(string.Empty, dto.Extra); + } + + [Fact] + public void FromDto_EmptyString_BecomesNullProperty() + { + var dto = new AuditEventDto + { + EventId = Guid.NewGuid().ToString(), + OccurredAtUtc = Timestamp.FromDateTime(DateTime.UtcNow), + Channel = nameof(AuditChannel.ApiOutbound), + Kind = nameof(AuditKind.ApiCall), + Status = nameof(AuditStatus.Submitted), + CorrelationId = string.Empty, + SourceSiteId = string.Empty, + SourceInstanceId = string.Empty, + SourceScript = string.Empty, + Actor = string.Empty, + Target = string.Empty, + ErrorMessage = string.Empty, + ErrorDetail = string.Empty, + RequestSummary = string.Empty, + ResponseSummary = string.Empty, + Extra = string.Empty + }; + + var evt = AuditEventMapper.FromDto(dto); + + 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.ErrorMessage); + Assert.Null(evt.ErrorDetail); + Assert.Null(evt.RequestSummary); + Assert.Null(evt.ResponseSummary); + Assert.Null(evt.Extra); + } + + [Fact] + public void ToDto_OccurredAtUtc_PreservesUtcKind() + { + var occurredAt = new DateTime(2026, 5, 20, 8, 0, 0, DateTimeKind.Utc); + var evt = new AuditEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = occurredAt, + Channel = AuditChannel.DbOutbound, + Kind = AuditKind.DbWrite, + Status = AuditStatus.Delivered + }; + + var dto = AuditEventMapper.ToDto(evt); + var roundTripped = AuditEventMapper.FromDto(dto); + + Assert.Equal(DateTimeKind.Utc, roundTripped.OccurredAtUtc.Kind); + Assert.Equal(occurredAt, roundTripped.OccurredAtUtc); + } + + [Fact] + public void ToDto_NullableInt_BecomesNullInt32Value() + { + var evt = new AuditEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = DateTime.UtcNow, + Channel = AuditChannel.Notification, + Kind = AuditKind.NotifySend, + Status = AuditStatus.Submitted, + HttpStatus = null, + DurationMs = null + }; + + var dto = AuditEventMapper.ToDto(evt); + + Assert.Null(dto.HttpStatus); + Assert.Null(dto.DurationMs); + } + + [Fact] + public void FromDto_NullInt32Value_BecomesNullProperty() + { + var dto = new AuditEventDto + { + EventId = Guid.NewGuid().ToString(), + OccurredAtUtc = Timestamp.FromDateTime(DateTime.UtcNow), + Channel = nameof(AuditChannel.ApiInbound), + Kind = nameof(AuditKind.InboundRequest), + Status = nameof(AuditStatus.Delivered) + // HttpStatus + DurationMs intentionally left absent + }; + + Assert.Null(dto.HttpStatus); + Assert.Null(dto.DurationMs); + + var evt = AuditEventMapper.FromDto(dto); + + Assert.Null(evt.HttpStatus); + Assert.Null(evt.DurationMs); + } + + [Fact] + public void ToDto_EnumValues_StoredAsStringNames() + { + var evt = new AuditEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = DateTime.UtcNow, + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCallCached, + Status = AuditStatus.Parked + }; + + var dto = AuditEventMapper.ToDto(evt); + + Assert.Equal("ApiOutbound", dto.Channel); + Assert.Equal("ApiCallCached", dto.Kind); + Assert.Equal("Parked", dto.Status); + } +}