feat(commons): add AuditEvent record (#23)
This commit is contained in:
73
src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs
Normal file
73
src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
|
||||
namespace ScadaLink.Commons.Entities.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Single source of truth for AuditLog (#23) rows. Central rows leave ForwardState null;
|
||||
/// site rows leave IngestedAtUtc null until ingest. Append-only.
|
||||
/// </summary>
|
||||
public sealed record AuditEvent
|
||||
{
|
||||
/// <summary>Idempotency key; uniquely identifies one audit lifecycle event.</summary>
|
||||
public Guid EventId { get; init; }
|
||||
|
||||
/// <summary>UTC timestamp when the audited action occurred at its source.</summary>
|
||||
public DateTime OccurredAtUtc { get; init; }
|
||||
|
||||
/// <summary>UTC timestamp when the row was ingested at central; null on the site hot-path.</summary>
|
||||
public DateTime? IngestedAtUtc { get; init; }
|
||||
|
||||
/// <summary>Trust-boundary channel the audited action crossed.</summary>
|
||||
public AuditChannel Channel { get; init; }
|
||||
|
||||
/// <summary>Specific event kind within the channel (see alog.md §4).</summary>
|
||||
public AuditKind Kind { get; init; }
|
||||
|
||||
/// <summary>Correlation id linking related audit rows (e.g. the cached-op lifecycle).</summary>
|
||||
public Guid? CorrelationId { get; init; }
|
||||
|
||||
/// <summary>Site id where the action originated; null for central-direct events.</summary>
|
||||
public string? SourceSiteId { get; init; }
|
||||
|
||||
/// <summary>Instance id where the action originated, when applicable.</summary>
|
||||
public string? SourceInstanceId { get; init; }
|
||||
|
||||
/// <summary>Script that initiated the action (script trust boundary), when applicable.</summary>
|
||||
public string? SourceScript { get; init; }
|
||||
|
||||
/// <summary>Authenticated actor for inbound paths (API key name, user, etc.).</summary>
|
||||
public string? Actor { get; init; }
|
||||
|
||||
/// <summary>Target of the action: external system name, db connection name, list name, or inbound method.</summary>
|
||||
public string? Target { get; init; }
|
||||
|
||||
/// <summary>Lifecycle status of this row.</summary>
|
||||
public AuditStatus Status { get; init; }
|
||||
|
||||
/// <summary>HTTP status code where applicable (outbound API + inbound API).</summary>
|
||||
public int? HttpStatus { get; init; }
|
||||
|
||||
/// <summary>Duration of the audited action in milliseconds, when measurable.</summary>
|
||||
public int? DurationMs { get; init; }
|
||||
|
||||
/// <summary>Human-readable error summary on failure rows.</summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>Verbose error detail (stack/exception) on failure rows.</summary>
|
||||
public string? ErrorDetail { get; init; }
|
||||
|
||||
/// <summary>Truncated/redacted request summary; capped per AuditLogOptions.</summary>
|
||||
public string? RequestSummary { get; init; }
|
||||
|
||||
/// <summary>Truncated/redacted response summary; capped per AuditLogOptions.</summary>
|
||||
public string? ResponseSummary { get; init; }
|
||||
|
||||
/// <summary>True when Request/Response summaries were truncated to the payload cap.</summary>
|
||||
public bool PayloadTruncated { get; init; }
|
||||
|
||||
/// <summary>Free-form JSON extension column for channel-specific extras.</summary>
|
||||
public string? Extra { get; init; }
|
||||
|
||||
/// <summary>Site-local forwarding state; null on central rows.</summary>
|
||||
public AuditForwardState? ForwardState { get; init; }
|
||||
}
|
||||
135
tests/ScadaLink.Commons.Tests/Entities/Audit/AuditEventTests.cs
Normal file
135
tests/ScadaLink.Commons.Tests/Entities/Audit/AuditEventTests.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
|
||||
namespace ScadaLink.Commons.Tests.Entities.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="AuditEvent"/> behaves as an init-only record:
|
||||
/// every property reads back as constructed, and <c>with</c> expressions
|
||||
/// produce a new instance with a single property changed.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user