using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using NSubstitute.ExceptionExtensions; using ZB.MOM.WW.Audit; using ZB.MOM.WW.ScadaBridge.AuditLog.Central; using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central; /// /// Review 2026-10-21 06/IMPL-1 adversarial follow-up: central reports a row as REJECTED only for a /// permanent, row-intrinsic SQL failure; a database outage / timeout leaves the id in neither list, /// so the site never mistakes a maintenance window for a poison row. Also pins the classifier the /// reconciliation puller uses to decide what counts toward permanent abandonment (06/COR follow-up 3). /// public class AuditIngestRejectionTests : TestKit { private static AuditEvent NewEvent() => ScadaBridgeAuditEventFactory.Create( eventId: Guid.NewGuid(), occurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc), channel: AuditChannel.ApiOutbound, kind: AuditKind.ApiCall, status: AuditStatus.Delivered, sourceSiteId: "site-a"); /// The batch reply lists only the over-width row as rejected; the timed-out row is in neither list. [Fact] public void Ingest_ReportsOnlyPermanentRowFailuresAsRejected() { var ok = NewEvent(); var overWidth = NewEvent(); var timedOut = NewEvent(); var repo = Substitute.For(); repo.InsertManyIfNotExistsAsync(Arg.Any>(), Arg.Any(), Arg.Any()) .ThrowsAsync(SqlExceptionFactory.Create(2628)); repo.InsertIfNotExistsAsync(Arg.Is(e => e.EventId == overWidth.EventId), Arg.Any()) .ThrowsAsync(SqlExceptionFactory.Create(2628)); repo.InsertIfNotExistsAsync(Arg.Is(e => e.EventId == timedOut.EventId), Arg.Any()) .ThrowsAsync(SqlExceptionFactory.Create(-2)); var actor = Sys.ActorOf(Props.Create(() => new AuditLogIngestActor( StubAuditRepositoryProvider.Build(repo), NullLogger.Instance))); actor.Tell(new IngestAuditEventsCommand(new[] { ok, overWidth, timedOut }), TestActor); var reply = ExpectMsg(TimeSpan.FromSeconds(10)); Assert.Equal(new[] { ok.EventId }, reply.AcceptedEventIds); Assert.Equal(new[] { overWidth.EventId }, reply.RejectedEventIds); } /// Classifier: row-intrinsic SQL errors are permanent; timeouts, connection loss and non-SQL faults are not. [Fact] public void Classifier_SeparatesRowVerdictsFromDatabaseProblems() { Assert.True(AuditIngestErrorClassifier.IsPermanentRowRejection(SqlExceptionFactory.Create(2628))); Assert.True(AuditIngestErrorClassifier.IsPermanentRowRejection(SqlExceptionFactory.Create(515))); Assert.True(AuditIngestErrorClassifier.IsPermanentRowRejection( new InvalidOperationException("wrapped", SqlExceptionFactory.Create(8152)))); Assert.False(AuditIngestErrorClassifier.IsPermanentRowRejection(SqlExceptionFactory.Create(-2))); Assert.False(AuditIngestErrorClassifier.IsPermanentRowRejection(new TimeoutException())); Assert.False(AuditIngestErrorClassifier.IsPermanentRowRejection(new InvalidOperationException("bug"))); Assert.True(AuditIngestErrorClassifier.IsTransient(SqlExceptionFactory.Create(-2))); Assert.True(AuditIngestErrorClassifier.IsTransient(SqlExceptionFactory.Create(1205))); Assert.True(AuditIngestErrorClassifier.IsTransient(SqlExceptionFactory.Create(40613))); Assert.True(AuditIngestErrorClassifier.IsTransient(new TimeoutException())); Assert.True(AuditIngestErrorClassifier.IsTransient(new TaskCanceledException())); Assert.True(AuditIngestErrorClassifier.IsTransient( new InvalidOperationException("wrapped", new System.Net.Sockets.SocketException()))); Assert.False(AuditIngestErrorClassifier.IsTransient(SqlExceptionFactory.Create(2628))); Assert.False(AuditIngestErrorClassifier.IsTransient(new InvalidOperationException("bug"))); } private sealed class OneRowClient : IPullAuditEventsClient { private readonly AuditEvent _row; public OneRowClient(AuditEvent row) => _row = row; public System.Collections.Concurrent.ConcurrentQueue Since { get; } = new(); public Task PullAsync( string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct) { Since.Enqueue(sinceUtc); return Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration.PullAuditEventsResponse( new[] { _row }, MoreAvailable: false)); } } private sealed class OneSite : ISiteEnumerator { public Task> EnumerateAsync(CancellationToken ct = default) => Task.FromResult>(new[] { new SiteEntry("site-a", "http://a:8083") }); } /// /// A row that keeps hitting a TRANSIENT database fault is retried with the cursor held back for /// far longer than the permanent-abandon threshold, and is never abandoned. /// [Fact] public void Reconciliation_TransientFaults_NeverCountTowardAbandonment() { var row = NewEvent(); var repo = Substitute.For(); repo.InsertManyIfNotExistsAsync(Arg.Any>(), Arg.Any(), Arg.Any()) .ThrowsAsync(SqlExceptionFactory.Create(-2)); repo.InsertIfNotExistsAsync(Arg.Any(), Arg.Any()) .ThrowsAsync(SqlExceptionFactory.Create(-2)); var client = new OneRowClient(row); var actor = Sys.ActorOf(Props.Create(() => new SiteAuditReconciliationActor( new OneSite(), client, StubAuditRepositoryProvider.Build(repo), Microsoft.Extensions.Options.Options.Create(new SiteAuditReconciliationOptions { ReconciliationIntervalOverride = TimeSpan.FromMilliseconds(30), }), NullLogger.Instance))); AwaitCondition(() => client.Since.Count >= 12, TimeSpan.FromSeconds(10)); Watch(actor); Sys.Stop(actor); ExpectTerminated(actor); Assert.All(client.Since, s => Assert.Equal(DateTime.MinValue, s)); // cursor held every tick repo.DidNotReceive().InsertIfNotExistsAsync( Arg.Is(e => e.Action.EndsWith(".ReconciliationAbandoned", StringComparison.Ordinal)), Arg.Any()); } }