feat(auditlog): AuditEvent ↔ proto mapper (#23)
This commit is contained in:
@@ -21,6 +21,8 @@
|
|||||||
IAuditLogRepository is registered by ScadaLink.ConfigurationDatabase; the project
|
IAuditLogRepository is registered by ScadaLink.ConfigurationDatabase; the project
|
||||||
reference is documented here so M2 writers + telemetry actors can depend on it. -->
|
reference is documented here so M2 writers + telemetry actors can depend on it. -->
|
||||||
<ProjectReference Include="../ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj" />
|
<ProjectReference Include="../ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj" />
|
||||||
|
<!-- Communication carries the IngestAuditEvents proto + DTOs (#23 M2 site sync). -->
|
||||||
|
<ProjectReference Include="../ScadaLink.Communication/ScadaLink.Communication.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
112
src/ScadaLink.AuditLog/Telemetry/AuditEventMapper.cs
Normal file
112
src/ScadaLink.AuditLog/Telemetry/AuditEventMapper.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bridges Audit Log (#23) rows between the in-process <see cref="AuditEvent"/> record
|
||||||
|
/// and the wire-format <see cref="AuditEventDto"/> exchanged over the
|
||||||
|
/// <c>IngestAuditEvents</c> RPC.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para><b>Lossy by design:</b> the proto contract intentionally omits two fields.</para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><see cref="AuditEvent.ForwardState"/> — site-local SQLite state, never travels.</item>
|
||||||
|
/// <item><see cref="AuditEvent.IngestedAtUtc"/> — central-set at ingest time, not at the site.</item>
|
||||||
|
/// </list>
|
||||||
|
/// <para>
|
||||||
|
/// 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
|
||||||
|
/// <c>Int32Value</c> wrapper so they preserve true null semantics.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class AuditEventMapper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Projects an <see cref="AuditEvent"/> into its wire-format DTO. Null reference
|
||||||
|
/// fields collapse to empty strings; null integer fields leave the wrapper unset.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reconstructs an <see cref="AuditEvent"/> from its wire-format DTO. Empty strings
|
||||||
|
/// rehydrate as null reference values; absent integer wrappers stay null.
|
||||||
|
/// <see cref="AuditEvent.ForwardState"/> and <see cref="AuditEvent.IngestedAtUtc"/>
|
||||||
|
/// are intentionally left null — the central ingest actor sets the latter.
|
||||||
|
/// </summary>
|
||||||
|
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<AuditChannel>(dto.Channel),
|
||||||
|
Kind = Enum.Parse<AuditKind>(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<AuditStatus>(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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Round-trip + edge tests for the <see cref="AuditEventMapper"/> that bridges
|
||||||
|
/// <see cref="AuditEvent"/> (Commons) ↔ <see cref="AuditEventDto"/> (proto).
|
||||||
|
/// ForwardState is site-local and IngestedAtUtc is central-set, so neither survives
|
||||||
|
/// the proto round-trip.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user