feat(health): CentralAuditWriteFailures + AuditCentralHealthSnapshot (#23 M6)
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ScadaLink.AuditLog.Central;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Messages.Audit;
|
||||
using ScadaLink.Commons.Types.Audit;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
|
||||
namespace ScadaLink.AuditLog.Tests.Central;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle E (M6-T8) regression coverage for the central-side audit-write
|
||||
/// failure counter. <see cref="CentralAuditWriter"/> and
|
||||
/// <see cref="AuditLogIngestActor"/> both swallow repository throws (audit
|
||||
/// must NEVER abort the user-facing action, alog.md §13) but bump the
|
||||
/// <see cref="ICentralAuditWriteFailureCounter"/> so the central health
|
||||
/// surface (<see cref="AuditCentralHealthSnapshot"/>) can flag a sustained
|
||||
/// outage.
|
||||
/// </summary>
|
||||
public class CentralAuditWriteFailuresTests : TestKit
|
||||
{
|
||||
private static AuditEvent NewEvent() => new()
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTime.UtcNow,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
Status = AuditStatus.Delivered,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Repository stub that always throws on insert — exercises the failure
|
||||
/// path in both <see cref="CentralAuditWriter"/> and
|
||||
/// <see cref="AuditLogIngestActor"/>.
|
||||
/// </summary>
|
||||
private sealed class ThrowingRepo : IAuditLogRepository
|
||||
{
|
||||
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) =>
|
||||
throw new InvalidOperationException("simulated repo failure");
|
||||
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
||||
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>());
|
||||
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default) =>
|
||||
Task.FromResult(0L);
|
||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||
DateTime threshold, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory <see cref="ICentralAuditWriteFailureCounter"/> recording
|
||||
/// every <see cref="Increment"/> call so tests can assert on the count.
|
||||
/// </summary>
|
||||
private sealed class RecordingFailureCounter : ICentralAuditWriteFailureCounter
|
||||
{
|
||||
private int _count;
|
||||
public int Count => Volatile.Read(ref _count);
|
||||
public void Increment() => Interlocked.Increment(ref _count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Forced_Failure_Increments_Counter()
|
||||
{
|
||||
// Direct test: build the writer with a throwing scope and verify the
|
||||
// injected counter is bumped on the swallowed insert exception.
|
||||
var counter = new RecordingFailureCounter();
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped<IAuditLogRepository, ThrowingRepo>();
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var writer = new CentralAuditWriter(
|
||||
sp,
|
||||
NullLogger<CentralAuditWriter>.Instance,
|
||||
filter: null,
|
||||
failureCounter: counter);
|
||||
|
||||
// WriteAsync swallows the exception and increments the counter.
|
||||
await writer.WriteAsync(NewEvent());
|
||||
|
||||
Assert.Equal(1, counter.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuditLogIngestActor_Failure_Increments_Counter()
|
||||
{
|
||||
// The actor's production ctor resolves both IAuditLogRepository AND
|
||||
// ICentralAuditWriteFailureCounter from the scope per-message; we
|
||||
// register both and verify the per-row catch bumps the counter for
|
||||
// every row in the batch.
|
||||
var counter = new RecordingFailureCounter();
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped<IAuditLogRepository, ThrowingRepo>();
|
||||
// Counter is a singleton — the actor's per-message scope still
|
||||
// resolves the same instance via the scope's parent provider.
|
||||
services.AddSingleton<ICentralAuditWriteFailureCounter>(counter);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var actor = Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
|
||||
sp, NullLogger<AuditLogIngestActor>.Instance)));
|
||||
|
||||
var batch = new[] { NewEvent(), NewEvent(), NewEvent() };
|
||||
var reply = await actor.Ask<IngestAuditEventsReply>(
|
||||
new IngestAuditEventsCommand(batch), TimeSpan.FromSeconds(5));
|
||||
|
||||
// Every row threw → none accepted, counter bumped once per row.
|
||||
Assert.Empty(reply.AcceptedEventIds);
|
||||
Assert.Equal(batch.Length, counter.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snapshot_Aggregates_Counters_And_StalledState()
|
||||
{
|
||||
// AuditCentralHealthSnapshot implements both writer surfaces; bumping
|
||||
// through the writer interfaces is reflected on the read surface, and
|
||||
// SiteAuditTelemetryStalled is sourced from the injected tracker.
|
||||
using var tracker = new SiteAuditTelemetryStalledTracker(Sys);
|
||||
var snapshot = new AuditCentralHealthSnapshot(tracker);
|
||||
|
||||
Assert.Equal(0, snapshot.CentralAuditWriteFailures);
|
||||
Assert.Equal(0, snapshot.AuditRedactionFailure);
|
||||
Assert.Empty(snapshot.SiteAuditTelemetryStalled);
|
||||
|
||||
((ICentralAuditWriteFailureCounter)snapshot).Increment();
|
||||
((ICentralAuditWriteFailureCounter)snapshot).Increment();
|
||||
((ScadaLink.AuditLog.Payload.IAuditRedactionFailureCounter)snapshot).Increment();
|
||||
|
||||
// Publish a stalled-changed event so the tracker registers a site.
|
||||
Sys.EventStream.Publish(new SiteAuditTelemetryStalledChanged("siteA", Stalled: true));
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var stalledMap = snapshot.SiteAuditTelemetryStalled;
|
||||
Assert.True(stalledMap.TryGetValue("siteA", out var s) && s,
|
||||
"expected siteA to be stalled in snapshot");
|
||||
},
|
||||
duration: TimeSpan.FromSeconds(2),
|
||||
interval: TimeSpan.FromMilliseconds(20));
|
||||
|
||||
Assert.Equal(2, snapshot.CentralAuditWriteFailures);
|
||||
Assert.Equal(1, snapshot.AuditRedactionFailure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditCentralHealthSnapshot_Construction_Without_Tracker_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => new AuditCentralHealthSnapshot(null!));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user