refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,310 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests;
/// <summary>
/// Bundle E (M2 Task E1) DI surface tests for <c>AddAuditLog</c>. M1 shipped
/// the options-only scaffold; M2 extends it with the site writer chain
/// (<see cref="SqliteAuditWriter"/> + <see cref="RingBufferFallback"/> +
/// <see cref="FallbackAuditWriter"/>) and the telemetry collaborators
/// (<see cref="ISiteAuditQueue"/>, <see cref="ISiteStreamAuditClient"/>,
/// <see cref="IAuditWriteFailureCounter"/>).
/// </summary>
public class AddAuditLogTests
{
private static ServiceProvider BuildProvider(IDictionary<string, string?>? settings = null)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(settings ?? new Dictionary<string, string?>())
.Build();
var services = new ServiceCollection();
services.AddSingleton<ILoggerFactory, NullLoggerFactory>();
services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
// INodeIdentityProvider is registered by the Host's
// SiteServiceRegistration in production; AddAuditLog assumes its
// presence so SqliteAuditWriter and CentralAuditWriter can resolve.
services.AddSingleton<INodeIdentityProvider>(new FakeNodeIdentityProvider());
services.AddAuditLog(config);
return services.BuildServiceProvider();
}
[Fact]
public void AddAuditLog_RegistersAuditLogOptions()
{
using var provider = BuildProvider();
var opts = provider.GetService<IOptions<AuditLogOptions>>();
Assert.NotNull(opts);
Assert.NotNull(opts!.Value);
}
[Fact]
public void AddAuditLog_NullServices_Throws()
{
var config = new ConfigurationBuilder().Build();
Assert.Throws<ArgumentNullException>(
() => ServiceCollectionExtensions.AddAuditLog(null!, config));
}
[Fact]
public void AddAuditLog_NullConfig_Throws()
{
var services = new ServiceCollection();
Assert.Throws<ArgumentNullException>(
() => services.AddAuditLog(null!));
}
// -- Bundle E (M2 Task E1) ---------------------------------------------
[Fact]
public void AddAuditLog_Registers_SqliteAuditWriter_Singleton_FromDI()
{
using var provider = BuildProvider(new Dictionary<string, string?>
{
// In-memory database keeps the writer's owned connection portable
// across tests; the per-instance Cache=Shared in the writer's
// default connection string ensures no on-disk file is touched.
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
});
var writer = provider.GetService<SqliteAuditWriter>();
Assert.NotNull(writer);
// Singleton — same instance on a second resolve.
Assert.Same(writer, provider.GetService<SqliteAuditWriter>());
}
[Fact]
public void AddAuditLog_Registers_IAuditWriter_AsFallbackAuditWriter()
{
using var provider = BuildProvider(new Dictionary<string, string?>
{
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
});
var writer = provider.GetService<IAuditWriter>();
Assert.NotNull(writer);
Assert.IsType<FallbackAuditWriter>(writer);
}
[Fact]
public void AddAuditLog_Registers_ISiteAuditQueue_AsSameInstance_As_SqliteAuditWriter()
{
// The telemetry actor reads from ISiteAuditQueue while scripts write
// through IAuditWriter → SqliteAuditWriter. Both surfaces MUST resolve
// to the same instance or pending rows will never be visible.
using var provider = BuildProvider(new Dictionary<string, string?>
{
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
});
var queue = provider.GetService<ISiteAuditQueue>();
var writer = provider.GetService<SqliteAuditWriter>();
Assert.NotNull(queue);
Assert.NotNull(writer);
Assert.Same(writer, queue);
}
[Fact]
public void AddAuditLog_Registers_RingBufferFallback_Singleton()
{
using var provider = BuildProvider(new Dictionary<string, string?>
{
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
});
var ring = provider.GetService<RingBufferFallback>();
Assert.NotNull(ring);
Assert.Same(ring, provider.GetService<RingBufferFallback>());
}
[Fact]
public void AddAuditLog_Registers_AuditWriteFailureCounter_AsNoOpDefault()
{
using var provider = BuildProvider(new Dictionary<string, string?>
{
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
});
var counter = provider.GetService<IAuditWriteFailureCounter>();
Assert.NotNull(counter);
Assert.IsType<NoOpAuditWriteFailureCounter>(counter);
}
[Fact]
public void AddAuditLog_Registers_SiteStreamAuditClient_AsNoOpDefault()
{
using var provider = BuildProvider(new Dictionary<string, string?>
{
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
});
var client = provider.GetService<ISiteStreamAuditClient>();
Assert.NotNull(client);
Assert.IsType<NoOpSiteStreamAuditClient>(client);
}
// -- M4 Bundle B (B1) central direct-write audit writer -----------------
[Fact]
public void AddAuditLog_Registers_ICentralAuditWriter_AsCentralAuditWriter()
{
using var provider = BuildProvider(new Dictionary<string, string?>
{
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
});
var writer = provider.GetService<ICentralAuditWriter>();
Assert.NotNull(writer);
Assert.IsType<CentralAuditWriter>(writer);
}
[Fact]
public void AddAuditLog_ICentralAuditWriter_IsSingleton()
{
using var provider = BuildProvider(new Dictionary<string, string?>
{
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
});
var w1 = provider.GetService<ICentralAuditWriter>();
var w2 = provider.GetService<ICentralAuditWriter>();
Assert.Same(w1, w2);
}
[Fact]
public void AddAuditLog_Options_Bind_RoundTrip_SqliteWriter()
{
using var provider = BuildProvider(new Dictionary<string, string?>
{
["AuditLog:SiteWriter:DatabasePath"] = "/tmp/test-audit.db",
["AuditLog:SiteWriter:ChannelCapacity"] = "8192",
["AuditLog:SiteWriter:BatchSize"] = "128",
["AuditLog:SiteWriter:FlushIntervalMs"] = "75",
});
var opts = provider.GetRequiredService<IOptions<SqliteAuditWriterOptions>>().Value;
Assert.Equal("/tmp/test-audit.db", opts.DatabasePath);
Assert.Equal(8192, opts.ChannelCapacity);
Assert.Equal(128, opts.BatchSize);
Assert.Equal(75, opts.FlushIntervalMs);
}
[Fact]
public void AddAuditLog_Options_Bind_RoundTrip_SiteTelemetry()
{
using var provider = BuildProvider(new Dictionary<string, string?>
{
["AuditLog:SiteTelemetry:BatchSize"] = "512",
["AuditLog:SiteTelemetry:BusyIntervalSeconds"] = "3",
["AuditLog:SiteTelemetry:IdleIntervalSeconds"] = "60",
});
var opts = provider.GetRequiredService<IOptions<SiteAuditTelemetryOptions>>().Value;
Assert.Equal(512, opts.BatchSize);
Assert.Equal(3, opts.BusyIntervalSeconds);
Assert.Equal(60, opts.IdleIntervalSeconds);
}
// -- Bundle G (M2 Task G1) Site Health Monitoring bridge ----------------
[Fact]
public void AddAuditLogHealthMetricsBridge_Swaps_FailureCounter_To_HealthMetrics_Implementation()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
})
.Build();
var services = new ServiceCollection();
services.AddSingleton<ILoggerFactory, NullLoggerFactory>();
services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
services.AddAuditLog(config);
// The bridge depends on ISiteHealthCollector; AddHealthMonitoring is
// what registers it on the site (and the central self-host).
services.AddHealthMonitoring();
services.AddAuditLogHealthMetricsBridge();
using var provider = services.BuildServiceProvider();
var counter = provider.GetRequiredService<IAuditWriteFailureCounter>();
Assert.IsType<HealthMetricsAuditWriteFailureCounter>(counter);
}
[Fact]
public void AddAuditLogHealthMetricsBridge_Without_HealthMonitoring_Still_Resolves_But_Errors_On_Use()
{
// The bridge replaces the registration unconditionally; resolving the
// counter when ISiteHealthCollector is missing throws at GetRequiredService
// time. This documents the contract — callers must register
// AddHealthMonitoring() before the bridge.
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
})
.Build();
var services = new ServiceCollection();
services.AddSingleton<ILoggerFactory, NullLoggerFactory>();
services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
services.AddAuditLog(config);
services.AddAuditLogHealthMetricsBridge();
using var provider = services.BuildServiceProvider();
Assert.Throws<InvalidOperationException>(
() => provider.GetRequiredService<IAuditWriteFailureCounter>());
}
[Fact]
public void AddAuditLogHealthMetricsBridge_IsIdempotent_DoesNotDoubleRegister_HostedService()
{
// AuditLog-011: AddHostedService has no TryAdd variant, so a second
// call without the sentinel guard would spin up a second
// SiteAuditBacklogReporter on the same SQLite file. The helper must
// be a no-op on the second call — exactly one hosted-service
// descriptor for SiteAuditBacklogReporter survives.
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
})
.Build();
var services = new ServiceCollection();
services.AddSingleton<ILoggerFactory, NullLoggerFactory>();
services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
services.AddSingleton<INodeIdentityProvider>(new FakeNodeIdentityProvider());
services.AddAuditLog(config);
services.AddHealthMonitoring();
services.AddAuditLogHealthMetricsBridge();
services.AddAuditLogHealthMetricsBridge();
var reporterCount = services.Count(d =>
d.ServiceType == typeof(IHostedService) &&
d.ImplementationType == typeof(SiteAuditBacklogReporter));
Assert.Equal(1, reporterCount);
}
}
@@ -0,0 +1,403 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Bundle D D2 tests for <see cref="AuditLogIngestActor"/>'s M3 combined-
/// telemetry dual-write transaction. Uses the same <see cref="MsSqlMigrationFixture"/>
/// as the M1 + M2 repository tests so the actor exercises real
/// <see cref="AuditLogRepository.InsertIfNotExistsAsync"/> +
/// <see cref="SiteCallAuditRepository.UpsertAsync"/> against a per-test MSSQL
/// database. The transaction commits or rolls back inside one
/// <see cref="DbContext.Database"/>.
/// </summary>
public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public AuditLogIngestActorCombinedTelemetryTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
private ScadaBridgeDbContext CreateReadContext()
{
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer(_fixture.ConnectionString)
.Options;
return new ScadaBridgeDbContext(options);
}
private static string NewSiteId() =>
"test-bundle-d2-cached-" + Guid.NewGuid().ToString("N").Substring(0, 8);
private static (AuditEvent audit, SiteCall siteCall) NewEntry(
string siteId,
TrackedOperationId? trackedOperationId = null,
Guid? eventId = null,
string status = "Submitted",
AuditStatus auditStatus = AuditStatus.Submitted)
{
var trackedId = trackedOperationId ?? TrackedOperationId.New();
var now = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
var audit = new AuditEvent
{
EventId = eventId ?? Guid.NewGuid(),
OccurredAtUtc = now,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.CachedSubmit,
Status = auditStatus,
SourceSiteId = siteId,
CorrelationId = trackedId.Value,
};
var siteCall = new SiteCall
{
TrackedOperationId = trackedId,
Channel = "ApiOutbound",
Target = "ERP.GetOrder",
SourceSite = siteId,
Status = status,
RetryCount = 0,
CreatedAtUtc = now,
UpdatedAtUtc = now,
IngestedAtUtc = now, // overwritten by the actor
};
return (audit, siteCall);
}
/// <summary>
/// Builds a minimal DI container around the per-test MSSQL fixture's
/// connection string — DbContext + the two repositories the dual-write
/// handler resolves. Mirrors AddConfigurationDatabase without the
/// DataProtection wiring (we never write secret columns in these tests).
/// </summary>
private IServiceProvider BuildServiceProvider(
Func<ScadaBridgeDbContext, ISiteCallAuditRepository>? siteCallRepoFactory = null)
{
var services = new ServiceCollection();
services.AddDbContext<ScadaBridgeDbContext>(opts =>
opts.UseSqlServer(_fixture.ConnectionString)
.ConfigureWarnings(w => w.Ignore(
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
services.AddScoped<IAuditLogRepository>(sp =>
new AuditLogRepository(sp.GetRequiredService<ScadaBridgeDbContext>()));
if (siteCallRepoFactory is null)
{
services.AddScoped<ISiteCallAuditRepository>(sp =>
new SiteCallAuditRepository(sp.GetRequiredService<ScadaBridgeDbContext>()));
}
else
{
services.AddScoped(sp =>
siteCallRepoFactory(sp.GetRequiredService<ScadaBridgeDbContext>()));
}
return services.BuildServiceProvider();
}
private IActorRef CreateActor(IServiceProvider serviceProvider) =>
Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
serviceProvider,
NullLogger<AuditLogIngestActor>.Instance)));
[SkippableFact]
public async Task Receive_OneCachedPacket_WritesAuditRow_AND_SiteCallRow_AcksId()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var (audit, siteCall) = NewEntry(siteId);
var sp = BuildServiceProvider();
var actor = CreateActor(sp);
actor.Tell(
new IngestCachedTelemetryCommand(new[] { new CachedTelemetryEntry(audit, siteCall) }),
TestActor);
var reply = ExpectMsg<IngestCachedTelemetryReply>(TimeSpan.FromSeconds(15));
Assert.Single(reply.AcceptedEventIds);
Assert.Equal(audit.EventId, reply.AcceptedEventIds[0]);
// Verify rows landed in both tables.
await using var read = CreateReadContext();
var auditRow = await read.Set<AuditEvent>().SingleOrDefaultAsync(e => e.EventId == audit.EventId);
Assert.NotNull(auditRow);
Assert.NotNull(auditRow!.IngestedAtUtc);
var siteCallRow = await read.Set<SiteCall>()
.SingleOrDefaultAsync(s => s.TrackedOperationId == siteCall.TrackedOperationId);
Assert.NotNull(siteCallRow);
Assert.Equal(siteCall.Status, siteCallRow!.Status);
}
[SkippableFact]
public async Task Receive_DuplicateEventId_SameStatus_NoOp_RowCountUnchanged_AcksId()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var trackedId = TrackedOperationId.New();
var eventId = Guid.NewGuid();
var (audit, siteCall) = NewEntry(siteId, trackedId, eventId);
var sp = BuildServiceProvider();
var actor = CreateActor(sp);
// First write
actor.Tell(
new IngestCachedTelemetryCommand(new[] { new CachedTelemetryEntry(audit, siteCall) }),
TestActor);
ExpectMsg<IngestCachedTelemetryReply>(TimeSpan.FromSeconds(15));
// Second write — same EventId and TrackedOperationId, same status. Both
// the audit insert (idempotent) and the SiteCalls upsert (monotonic
// same-rank → no-op) should silently do nothing while still acking.
actor.Tell(
new IngestCachedTelemetryCommand(new[] { new CachedTelemetryEntry(audit, siteCall) }),
TestActor);
var reply = ExpectMsg<IngestCachedTelemetryReply>(TimeSpan.FromSeconds(15));
Assert.Single(reply.AcceptedEventIds);
Assert.Equal(eventId, reply.AcceptedEventIds[0]);
await using var read = CreateReadContext();
var auditCount = await read.Set<AuditEvent>().CountAsync(e => e.EventId == eventId);
Assert.Equal(1, auditCount);
var siteCallCount = await read.Set<SiteCall>()
.CountAsync(s => s.TrackedOperationId == trackedId);
Assert.Equal(1, siteCallCount);
}
[SkippableFact]
public async Task Receive_DuplicateEventId_AdvancedSiteCallStatus_UpdatesSiteCall()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var trackedId = TrackedOperationId.New();
var sp = BuildServiceProvider();
var actor = CreateActor(sp);
// 1st packet — Submitted (audit EventId #1, SiteCalls Status=Submitted).
var (auditSubmit, siteCallSubmit) = NewEntry(
siteId, trackedId, status: "Submitted", auditStatus: AuditStatus.Submitted);
actor.Tell(
new IngestCachedTelemetryCommand(new[] { new CachedTelemetryEntry(auditSubmit, siteCallSubmit) }),
TestActor);
ExpectMsg<IngestCachedTelemetryReply>(TimeSpan.FromSeconds(15));
// 2nd packet — Attempted with retry count 1 (audit EventId #2,
// SiteCalls Status=Attempted — monotonic upsert wins). Same
// TrackedOperationId throughout.
var (auditAttempt, siteCallAttempt) = NewEntry(
siteId, trackedId, status: "Attempted", auditStatus: AuditStatus.Attempted);
var advanced = siteCallAttempt with { RetryCount = 1, UpdatedAtUtc = siteCallAttempt.UpdatedAtUtc.AddMinutes(1) };
actor.Tell(
new IngestCachedTelemetryCommand(new[] { new CachedTelemetryEntry(auditAttempt, advanced) }),
TestActor);
var reply = ExpectMsg<IngestCachedTelemetryReply>(TimeSpan.FromSeconds(15));
Assert.Single(reply.AcceptedEventIds);
Assert.Equal(auditAttempt.EventId, reply.AcceptedEventIds[0]);
// Both audit rows exist.
await using var read = CreateReadContext();
var auditRows = await read.Set<AuditEvent>()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
Assert.Equal(2, auditRows.Count);
// SiteCalls row advanced to Attempted with retry count 1.
var siteCallRow = await read.Set<SiteCall>()
.SingleAsync(s => s.TrackedOperationId == trackedId);
Assert.Equal("Attempted", siteCallRow.Status);
Assert.Equal(1, siteCallRow.RetryCount);
}
[SkippableFact]
public async Task Receive_AuditInsertSucceeds_SiteCallThrows_BothRolledBack_NoOrphanRow()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var (audit, siteCall) = NewEntry(siteId);
// Wrap the SiteCalls repo so UpsertAsync always throws — the dual-write
// transaction must roll back the AuditLog INSERT done in the same
// transaction, leaving no orphan row.
var sp = BuildServiceProvider(
ctx => new ThrowingSiteCallRepo(new SiteCallAuditRepository(ctx)));
var actor = CreateActor(sp);
actor.Tell(
new IngestCachedTelemetryCommand(new[] { new CachedTelemetryEntry(audit, siteCall) }),
TestActor);
var reply = ExpectMsg<IngestCachedTelemetryReply>(TimeSpan.FromSeconds(15));
Assert.Empty(reply.AcceptedEventIds);
await using var read = CreateReadContext();
var auditRow = await read.Set<AuditEvent>().SingleOrDefaultAsync(e => e.EventId == audit.EventId);
Assert.Null(auditRow);
var siteCallRow = await read.Set<SiteCall>()
.SingleOrDefaultAsync(s => s.TrackedOperationId == siteCall.TrackedOperationId);
Assert.Null(siteCallRow);
}
[SkippableFact]
public async Task Receive_FiveCachedPackets_AllPersistedSeparately_AllAcked()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var entries = Enumerable.Range(0, 5).Select(_ =>
{
var (audit, siteCall) = NewEntry(siteId);
return new CachedTelemetryEntry(audit, siteCall);
}).ToList();
var sp = BuildServiceProvider();
var actor = CreateActor(sp);
actor.Tell(new IngestCachedTelemetryCommand(entries), TestActor);
var reply = ExpectMsg<IngestCachedTelemetryReply>(TimeSpan.FromSeconds(15));
Assert.Equal(5, reply.AcceptedEventIds.Count);
Assert.True(entries.Select(e => e.Audit.EventId).ToHashSet()
.SetEquals(reply.AcceptedEventIds.ToHashSet()));
await using var read = CreateReadContext();
var auditCount = await read.Set<AuditEvent>().CountAsync(e => e.SourceSiteId == siteId);
Assert.Equal(5, auditCount);
var siteCallCount = await read.Set<SiteCall>().CountAsync(s => s.SourceSite == siteId);
Assert.Equal(5, siteCallCount);
}
[SkippableFact]
public async Task Receive_OnePacketSucceeds_NextPacketThrows_FirstStillCommitted_BatchContinues()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var (audit1, siteCall1) = NewEntry(siteId);
var (audit2, siteCall2) = NewEntry(siteId);
var (audit3, siteCall3) = NewEntry(siteId);
var poisonTrackedId = siteCall2.TrackedOperationId;
// Throw only for the middle entry's TrackedOperationId — entries on
// either side must commit their own transactions independently.
var sp = BuildServiceProvider(
ctx => new PoisonOnIdSiteCallRepo(new SiteCallAuditRepository(ctx), poisonTrackedId));
var actor = CreateActor(sp);
actor.Tell(
new IngestCachedTelemetryCommand(new[]
{
new CachedTelemetryEntry(audit1, siteCall1),
new CachedTelemetryEntry(audit2, siteCall2),
new CachedTelemetryEntry(audit3, siteCall3),
}),
TestActor);
var reply = ExpectMsg<IngestCachedTelemetryReply>(TimeSpan.FromSeconds(15));
// Two entries committed; poison entry rolled back.
Assert.Equal(2, reply.AcceptedEventIds.Count);
Assert.Contains(audit1.EventId, reply.AcceptedEventIds);
Assert.Contains(audit3.EventId, reply.AcceptedEventIds);
Assert.DoesNotContain(audit2.EventId, reply.AcceptedEventIds);
await using var read = CreateReadContext();
var auditRows = await read.Set<AuditEvent>().Where(e => e.SourceSiteId == siteId).ToListAsync();
Assert.Equal(2, auditRows.Count);
Assert.DoesNotContain(auditRows, r => r.EventId == audit2.EventId);
var siteCallRows = await read.Set<SiteCall>().Where(s => s.SourceSite == siteId).ToListAsync();
Assert.Equal(2, siteCallRows.Count);
Assert.DoesNotContain(siteCallRows, r => r.TrackedOperationId == poisonTrackedId);
}
/// <summary>
/// Test double — throws unconditionally from <see cref="UpsertAsync"/> so
/// the dual-write transaction is forced to roll back. Lets the AuditLog
/// row insert succeed in-transaction; the rollback must remove it.
/// </summary>
private sealed class ThrowingSiteCallRepo : ISiteCallAuditRepository
{
private readonly ISiteCallAuditRepository _inner;
public ThrowingSiteCallRepo(ISiteCallAuditRepository inner) { _inner = inner; }
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) =>
throw new InvalidOperationException("simulated SiteCalls upsert failure");
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
_inner.GetAsync(id, ct);
public Task<IReadOnlyList<SiteCall>> QueryAsync(
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
_inner.QueryAsync(filter, paging, ct);
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
_inner.PurgeTerminalAsync(olderThanUtc, ct);
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
_inner.ComputeKpisAsync(stuckCutoff, intervalSince, ct);
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
_inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct);
}
/// <summary>
/// Test double — throws only when the supplied poison TrackedOperationId
/// is the one being upserted. Demonstrates per-entry transaction isolation:
/// one entry's failed transaction must not abort the batch's other entries.
/// </summary>
private sealed class PoisonOnIdSiteCallRepo : ISiteCallAuditRepository
{
private readonly ISiteCallAuditRepository _inner;
private readonly TrackedOperationId _poisonId;
public PoisonOnIdSiteCallRepo(ISiteCallAuditRepository inner, TrackedOperationId poisonId)
{
_inner = inner;
_poisonId = poisonId;
}
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default)
{
if (siteCall.TrackedOperationId == _poisonId)
{
throw new InvalidOperationException("simulated SiteCalls upsert failure for poison id");
}
return _inner.UpsertAsync(siteCall, ct);
}
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
_inner.GetAsync(id, ct);
public Task<IReadOnlyList<SiteCall>> QueryAsync(
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
_inner.QueryAsync(filter, paging, ct);
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
_inner.PurgeTerminalAsync(olderThanUtc, ct);
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
_inner.ComputeKpisAsync(stuckCutoff, intervalSince, ct);
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
_inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct);
}
}
@@ -0,0 +1,235 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
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;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Bundle D D2 tests for <see cref="AuditLogIngestActor"/>. Uses the same
/// <see cref="MsSqlMigrationFixture"/> as the M1 repository tests so the actor
/// exercises real <see cref="AuditLogRepository.InsertIfNotExistsAsync"/>
/// against a partitioned MSSQL schema (the only way to verify the
/// IngestedAtUtc stamp + duplicate-key idempotency end to end).
/// </summary>
public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public AuditLogIngestActorTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
private ScadaBridgeDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer(_fixture.ConnectionString)
.Options;
return new ScadaBridgeDbContext(options);
}
private static string NewSiteId() =>
"test-bundle-d2-" + Guid.NewGuid().ToString("N").Substring(0, 8);
private static AuditEvent NewEvent(string siteId, Guid? id = null) => new()
{
EventId = id ?? Guid.NewGuid(),
OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
SourceSiteId = siteId,
};
private IActorRef CreateActor(IAuditLogRepository repository) =>
Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
repository,
NullLogger<AuditLogIngestActor>.Instance)));
[SkippableFact]
public async Task Receive_BatchOf5_Calls_Repo_5Times_Acks_All_5()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var events = Enumerable.Range(0, 5).Select(_ => NewEvent(siteId)).ToList();
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
var actor = CreateActor(repo);
actor.Tell(new IngestAuditEventsCommand(events), TestActor);
var reply = ExpectMsg<IngestAuditEventsReply>(TimeSpan.FromSeconds(10));
Assert.Equal(5, reply.AcceptedEventIds.Count);
Assert.True(events.Select(e => e.EventId).ToHashSet().SetEquals(reply.AcceptedEventIds.ToHashSet()));
// Verify rows landed in MSSQL.
await using var readContext = CreateContext();
var rows = await readContext.Set<AuditEvent>()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
Assert.Equal(5, rows.Count);
}
[SkippableFact]
public async Task Receive_BatchWith_AlreadyExistingEvent_AcksAll_NoDoubleInsert()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var pre = NewEvent(siteId);
// Pre-insert one event directly via the repo so the actor sees it
// already present when it processes the batch.
await using (var seedContext = CreateContext())
{
var seedRepo = new AuditLogRepository(seedContext);
await seedRepo.InsertIfNotExistsAsync(pre);
}
// Build the batch including the pre-existing event plus 2 new ones.
var fresh1 = NewEvent(siteId);
var fresh2 = NewEvent(siteId);
var batch = new List<AuditEvent> { pre, fresh1, fresh2 };
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
var actor = CreateActor(repo);
actor.Tell(new IngestAuditEventsCommand(batch), TestActor);
var reply = ExpectMsg<IngestAuditEventsReply>(TimeSpan.FromSeconds(10));
// All 3 acked under idempotent first-write-wins.
Assert.Equal(3, reply.AcceptedEventIds.Count);
// Verify no double-insert.
await using var readContext = CreateContext();
var count = await readContext.Set<AuditEvent>()
.Where(e => e.SourceSiteId == siteId)
.CountAsync();
Assert.Equal(3, count);
}
[SkippableFact]
public async Task Receive_Sets_IngestedAtUtc_Before_Insert()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var events = Enumerable.Range(0, 3).Select(_ => NewEvent(siteId)).ToList();
var before = DateTime.UtcNow.AddSeconds(-1);
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
var actor = CreateActor(repo);
actor.Tell(new IngestAuditEventsCommand(events), TestActor);
ExpectMsg<IngestAuditEventsReply>(TimeSpan.FromSeconds(10));
var after = DateTime.UtcNow.AddSeconds(1);
await using var readContext = CreateContext();
var rows = await readContext.Set<AuditEvent>()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
Assert.Equal(3, rows.Count);
Assert.All(rows, r =>
{
Assert.NotNull(r.IngestedAtUtc);
Assert.InRange(r.IngestedAtUtc!.Value, before, after);
});
}
[SkippableFact]
public async Task Receive_RepoThrowsForOneEvent_Other4StillPersisted()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var events = Enumerable.Range(0, 5).Select(_ => NewEvent(siteId)).ToList();
var poisonId = events[2].EventId;
// Wrapper repo that throws only when the poison EventId is being
// inserted. The four neighbours must still land in MSSQL.
await using var context = CreateContext();
var realRepo = new AuditLogRepository(context);
var wrappedRepo = new ThrowingRepository(realRepo, poisonId);
var actor = CreateActor(wrappedRepo);
actor.Tell(new IngestAuditEventsCommand(events), TestActor);
var reply = ExpectMsg<IngestAuditEventsReply>(TimeSpan.FromSeconds(10));
// The actor catches the throw per-row, so 4 ids are accepted and 1 is
// left out.
Assert.Equal(4, reply.AcceptedEventIds.Count);
Assert.DoesNotContain(poisonId, reply.AcceptedEventIds);
await using var readContext = CreateContext();
var rows = await readContext.Set<AuditEvent>()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
Assert.Equal(4, rows.Count);
Assert.DoesNotContain(rows, r => r.EventId == poisonId);
}
/// <summary>
/// Tiny test double that delegates to a real repository but throws on a
/// specified EventId. Used to verify per-row failure isolation: one bad
/// row must not cause the rest of the batch to be lost.
/// </summary>
private sealed class ThrowingRepository : IAuditLogRepository
{
private readonly IAuditLogRepository _inner;
private readonly Guid _poisonId;
public ThrowingRepository(IAuditLogRepository inner, Guid poisonId)
{
_inner = inner;
_poisonId = poisonId;
}
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
{
if (evt.EventId == _poisonId)
{
throw new InvalidOperationException("simulated repo failure for poison row");
}
return _inner.InsertIfNotExistsAsync(evt, ct);
}
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
_inner.QueryAsync(filter, paging, ct);
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default) =>
_inner.SwitchOutPartitionAsync(monthBoundary, ct);
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
DateTime threshold, CancellationToken ct = default) =>
_inner.GetPartitionBoundariesOlderThanAsync(threshold, ct);
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
_inner.GetKpiSnapshotAsync(window, nowUtc, ct);
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
_inner.GetExecutionTreeAsync(executionId, ct);
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
_inner.GetDistinctSourceNodesAsync(ct);
}
}
@@ -0,0 +1,154 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
using Xunit;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Bundle D (#23 M6-T5) tests for <see cref="AuditLogPartitionMaintenanceService"/>.
/// All tests use an in-memory <see cref="IPartitionMaintenance"/> stub —
/// the real EF/MSSQL implementation is exercised by the
/// <c>AuditLogPartitionMaintenanceTests</c> integration suite in
/// <c>ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests</c>. This file is purely
/// about the hosted service's policy decisions (start/stop, exception
/// containment).
/// </summary>
public class AuditLogPartitionMaintenanceServiceTests
{
/// <summary>
/// Recording stub — counts EnsureLookaheadAsync invocations and lets the
/// test inject an exception per invocation to drive the catch-all path.
/// </summary>
private sealed class RecordingMaintenance : IPartitionMaintenance
{
public int EnsureCallCount;
public Exception? ThrowOnce;
public Task<IReadOnlyList<DateTime>> EnsureLookaheadAsync(int lookaheadMonths, CancellationToken ct = default)
{
Interlocked.Increment(ref EnsureCallCount);
if (ThrowOnce is { } ex)
{
ThrowOnce = null;
throw ex;
}
return Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
}
public Task<DateTime?> GetMaxBoundaryAsync(CancellationToken ct = default) =>
Task.FromResult<DateTime?>(DateTime.UtcNow.AddMonths(6));
}
/// <summary>
/// Captures logged exceptions so the catch-all assertion can prove
/// the exception was actually logged (not silently swallowed) and was
/// the exact instance the stub threw.
/// </summary>
private sealed class CapturingLogger : ILogger<AuditLogPartitionMaintenanceService>
{
public List<(LogLevel Level, Exception? Exception, string Message)> Entries { get; } = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
Entries.Add((logLevel, exception, formatter(state, exception)));
}
}
private static IServiceProvider BuildProvider(IPartitionMaintenance maintenance)
{
var services = new ServiceCollection();
// IPartitionMaintenance is registered as scoped by AddConfigurationDatabase;
// we mirror that here so the hosted service's CreateAsyncScope +
// GetRequiredService resolves the stub the test injected.
services.AddScoped(_ => maintenance);
return services.BuildServiceProvider();
}
[Fact]
public async Task StartStop_NoExceptions()
{
// Long interval so only the eager startup tick fires inside the test
// window — keeps assertions deterministic without relying on
// multiple cadence loops.
var opts = Options.Create(new AuditLogPartitionMaintenanceOptions
{
IntervalSeconds = 60,
LookaheadMonths = 1,
});
var maintenance = new RecordingMaintenance();
var sp = BuildProvider(maintenance);
var svc = new AuditLogPartitionMaintenanceService(
sp.GetRequiredService<IServiceScopeFactory>(),
opts,
NullLogger<AuditLogPartitionMaintenanceService>.Instance);
await svc.StartAsync(CancellationToken.None);
// Spin briefly until the startup tick has fired — the loop's first
// SafeMaintainAsync runs on a background Task.Run continuation, so
// we can't synchronously rely on its completion.
var deadline = DateTime.UtcNow.AddSeconds(3);
while (Volatile.Read(ref maintenance.EnsureCallCount) < 1 && DateTime.UtcNow < deadline)
{
await Task.Delay(20);
}
await svc.StopAsync(CancellationToken.None);
svc.Dispose();
Assert.True(maintenance.EnsureCallCount >= 1, $"expected at least 1 ensure call, got {maintenance.EnsureCallCount}");
}
[Fact]
public async Task SafeMaintain_ExceptionLogged_NotPropagated()
{
var opts = Options.Create(new AuditLogPartitionMaintenanceOptions
{
IntervalSeconds = 60,
LookaheadMonths = 1,
});
// The injected exception fires on the FIRST EnsureLookaheadAsync call
// (the startup tick) — the hosted service must contain it and
// continue running.
var boom = new InvalidOperationException("simulated maintenance failure");
var maintenance = new RecordingMaintenance { ThrowOnce = boom };
var sp = BuildProvider(maintenance);
var logger = new CapturingLogger();
var svc = new AuditLogPartitionMaintenanceService(
sp.GetRequiredService<IServiceScopeFactory>(),
opts,
logger);
// StartAsync must not throw even though the very first tick will fail.
await svc.StartAsync(CancellationToken.None);
// Wait for the error to surface in the logger.
var deadline = DateTime.UtcNow.AddSeconds(3);
while (!logger.Entries.Any(e => e.Exception == boom) && DateTime.UtcNow < deadline)
{
await Task.Delay(20);
}
await svc.StopAsync(CancellationToken.None);
svc.Dispose();
var errorEntry = Assert.Single(logger.Entries, e => e.Exception == boom);
Assert.Equal(LogLevel.Error, errorEntry.Level);
Assert.Equal(1, maintenance.EnsureCallCount);
}
}
@@ -0,0 +1,387 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Bundle C (#23 M6-T4) tests for <see cref="AuditLogPurgeActor"/>. The fast,
/// schedule-only tests substitute a recording stub for
/// <see cref="IAuditLogRepository"/> so the timer + per-boundary error-isolation
/// + event-publish machinery can be exercised without an MSSQL container.
/// The end-to-end "real partition gets switched out" assertion lives in the
/// repository tests (Bundle C of M6-T4); this actor file is purely about the
/// actor's policy decisions.
/// </summary>
public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public AuditLogPurgeActorTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
/// <summary>
/// In-memory recording stub. Captures every
/// <see cref="GetPartitionBoundariesOlderThanAsync"/> + every
/// <see cref="SwitchOutPartitionAsync"/> so tests can assert which boundaries
/// the actor chose to purge and how many ticks it issued. Also lets a
/// specific boundary be configured to throw so the continue-on-error path
/// is exercisable.
/// </summary>
private sealed class RecordingRepo : IAuditLogRepository
{
public List<DateTime> ThresholdQueries { get; } = new();
public List<DateTime> SwitchedBoundaries { get; } = new();
public Func<DateTime, long> RowsPerBoundary { get; set; } = _ => 0L;
public DateTime? ThrowOnBoundary { get; set; }
public Exception? BoundaryException { get; set; }
// The actor enumerator returns whichever list is configured here.
// Mutating this between ticks lets tests simulate "no longer
// eligible" boundaries on the second tick.
public List<DateTime> Boundaries { get; set; } = new();
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) =>
Task.CompletedTask;
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)
{
if (ThrowOnBoundary.HasValue && monthBoundary == ThrowOnBoundary.Value)
{
throw BoundaryException ?? new InvalidOperationException("simulated switch failure");
}
SwitchedBoundaries.Add(monthBoundary);
return Task.FromResult(RowsPerBoundary(monthBoundary));
}
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
DateTime threshold, CancellationToken ct = default)
{
ThresholdQueries.Add(threshold);
return Task.FromResult<IReadOnlyList<DateTime>>(Boundaries.ToArray());
}
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
}
private IServiceProvider BuildScopedProvider(IAuditLogRepository repo)
{
var services = new ServiceCollection();
// Mirror AddConfigurationDatabase: IAuditLogRepository is scoped, so
// the actor opens a fresh scope per tick and resolves there.
services.AddScoped(_ => repo);
return services.BuildServiceProvider();
}
private IActorRef CreateActor(
IAuditLogRepository repo,
AuditLogPurgeOptions purgeOptions,
AuditLogOptions? auditOptions = null)
{
var sp = BuildScopedProvider(repo);
return Sys.ActorOf(Props.Create(() => new AuditLogPurgeActor(
sp,
Options.Create(purgeOptions),
Options.Create(auditOptions ?? new AuditLogOptions()),
NullLogger<AuditLogPurgeActor>.Instance)));
}
private static AuditLogPurgeOptions FastTickOptions(TimeSpan? interval = null) => new()
{
IntervalHours = 24,
IntervalOverride = interval ?? TimeSpan.FromMilliseconds(100),
};
/// <summary>
/// Subscribe a probe to the EventStream so the test can observe
/// <see cref="AuditLogPurgedEvent"/> publications synchronously.
/// </summary>
private Akka.TestKit.TestProbe SubscribePurged()
{
var probe = CreateTestProbe();
Sys.EventStream.Subscribe(probe.Ref, typeof(AuditLogPurgedEvent));
return probe;
}
// ---------------------------------------------------------------------
// 1. Tick_Fires_OnDailyInterval
// ---------------------------------------------------------------------
[Fact]
public void Tick_Fires_OnDailyInterval()
{
var repo = new RecordingRepo();
CreateActor(repo, FastTickOptions());
// The first scheduled tick fires after the configured interval. We
// assert the visible side effect (the enumerator was called) rather
// than racing on internal state.
AwaitAssert(
() => Assert.True(repo.ThresholdQueries.Count >= 1,
$"expected >= 1 enumerator call, got {repo.ThresholdQueries.Count}"),
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
}
// ---------------------------------------------------------------------
// 2. Tick_OldPartitions_SwitchedOut
// ---------------------------------------------------------------------
[Fact]
public void Tick_OldPartitions_SwitchedOut()
{
var repo = new RecordingRepo
{
Boundaries = new List<DateTime>
{
new(2025, 11, 1, 0, 0, 0, DateTimeKind.Utc),
new(2025, 12, 1, 0, 0, 0, DateTimeKind.Utc),
},
RowsPerBoundary = _ => 42L,
};
CreateActor(repo, FastTickOptions());
AwaitAssert(
() =>
{
Assert.Contains(new DateTime(2025, 11, 1, 0, 0, 0, DateTimeKind.Utc), repo.SwitchedBoundaries);
Assert.Contains(new DateTime(2025, 12, 1, 0, 0, 0, DateTimeKind.Utc), repo.SwitchedBoundaries);
},
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
}
// ---------------------------------------------------------------------
// 3. Tick_NewerPartitions_Untouched
// ---------------------------------------------------------------------
[Fact]
public void Tick_NewerPartitions_Untouched()
{
// The actor's contract: it only touches whatever the enumerator
// returns. The enumerator (in production) filters out non-eligible
// boundaries; here we simulate that by handing back an empty list
// and asserting the actor switched nothing despite the tick firing.
var repo = new RecordingRepo { Boundaries = new List<DateTime>() };
CreateActor(repo, FastTickOptions());
// Wait for at least one tick (visible via the enumerator call) then
// assert no switch happened.
AwaitAssert(
() => Assert.True(repo.ThresholdQueries.Count >= 1),
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
Assert.Empty(repo.SwitchedBoundaries);
}
// ---------------------------------------------------------------------
// 4. Tick_PublishesPurgedEvent_WithRowCount
// ---------------------------------------------------------------------
[Fact]
public void Tick_PublishesPurgedEvent_WithRowCount()
{
var boundary = new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc);
var repo = new RecordingRepo
{
Boundaries = new List<DateTime> { boundary },
RowsPerBoundary = _ => 1234L,
};
var probe = SubscribePurged();
CreateActor(repo, FastTickOptions());
var msg = probe.ExpectMsg<AuditLogPurgedEvent>(TimeSpan.FromSeconds(5));
Assert.Equal(boundary, msg.MonthBoundary);
Assert.Equal(1234L, msg.RowsDeleted);
Assert.True(msg.DurationMs >= 0,
$"DurationMs should be non-negative; was {msg.DurationMs}");
}
// ---------------------------------------------------------------------
// 5. Tick_SwitchThrows_OtherPartitionsStillProcessed (continue-on-error)
// ---------------------------------------------------------------------
[Fact]
public void Tick_SwitchThrows_OtherPartitionsStillProcessed()
{
var poisonBoundary = new DateTime(2025, 7, 1, 0, 0, 0, DateTimeKind.Utc);
var goodBoundary = new DateTime(2025, 8, 1, 0, 0, 0, DateTimeKind.Utc);
var repo = new RecordingRepo
{
Boundaries = new List<DateTime> { poisonBoundary, goodBoundary },
ThrowOnBoundary = poisonBoundary,
BoundaryException = new InvalidOperationException("simulated switch failure for poison boundary"),
};
CreateActor(repo, FastTickOptions());
AwaitAssert(
() =>
{
// The good boundary was still switched even though the poison
// boundary threw.
Assert.Contains(goodBoundary, repo.SwitchedBoundaries);
Assert.DoesNotContain(poisonBoundary, repo.SwitchedBoundaries);
},
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
}
// ---------------------------------------------------------------------
// 6. EndToEnd_RealPartition_RowsRemoved_PurgedEventPublished
// ---------------------------------------------------------------------
[SkippableFact]
public async Task EndToEnd_RealPartition_RowsRemoved_PurgedEventPublished()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// Today is ~2026-05-20 per the test environment. With RetentionDays =
// 60 the actor computes threshold ≈ 2026-03-21:
// * Jan partition (MAX = Jan 15) → older than threshold → PURGED
// * Apr partition (MAX = Apr 15) → newer than threshold → KEPT
var siteId = "purge-e2e-" + Guid.NewGuid().ToString("N").Substring(0, 8);
var janEvt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
SourceSiteId = siteId,
};
var aprEvt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
SourceSiteId = siteId,
};
await using (var seedContext = CreateMsSqlContext())
{
var seedRepo = new AuditLogRepository(seedContext);
await seedRepo.InsertIfNotExistsAsync(janEvt);
await seedRepo.InsertIfNotExistsAsync(aprEvt);
}
// Wire the actor's DI scope to the real repository against the
// fixture's MSSQL database. The actor opens a fresh scope per tick,
// so register the context as scoped (mirroring the production
// AddConfigurationDatabase wiring).
var services = new ServiceCollection();
services.AddDbContext<ScadaBridgeDbContext>(
opts => opts.UseSqlServer(_fixture.ConnectionString),
ServiceLifetime.Scoped);
services.AddScoped<IAuditLogRepository, AuditLogRepository>();
var sp = services.BuildServiceProvider();
var auditOptions = new AuditLogOptions { RetentionDays = 60 };
var purgeOptions = new AuditLogPurgeOptions
{
IntervalHours = 24,
IntervalOverride = TimeSpan.FromMilliseconds(100),
};
var probe = SubscribePurged();
Sys.ActorOf(Props.Create(() => new AuditLogPurgeActor(
sp,
Options.Create(purgeOptions),
Options.Create(auditOptions),
NullLogger<AuditLogPurgeActor>.Instance)));
// The probe receives one AuditLogPurgedEvent per partition the actor
// purges per tick — other test runs that share the fixture DB may
// also leave behind eligible partitions, but this test creates its
// own fixture DB so the Jan-2026 partition is the only eligible one.
// Use FishForMessage to filter just in case, with a generous timeout
// because the real drop-and-rebuild dance against MSSQL routinely
// takes a couple of seconds on a busy dev container.
var janBoundary = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var matched = probe.FishForMessage<AuditLogPurgedEvent>(
isMessage: m => m.MonthBoundary == janBoundary,
max: TimeSpan.FromSeconds(30));
Assert.True(matched.RowsDeleted >= 1,
$"Expected RowsDeleted >= 1 for the Jan-2026 partition; got {matched.RowsDeleted}.");
// Settle: allow any in-flight tick to commit before reading.
await Task.Delay(TimeSpan.FromMilliseconds(500));
await using var verifyContext = CreateMsSqlContext();
var rows = await verifyContext.Set<AuditEvent>()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
Assert.DoesNotContain(rows, r => r.EventId == janEvt.EventId);
Assert.Contains(rows, r => r.EventId == aprEvt.EventId);
}
private ScadaBridgeDbContext CreateMsSqlContext() =>
new(new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer(_fixture.ConnectionString).Options);
// ---------------------------------------------------------------------
// 7. Threshold_UsesAuditLogOptionsRetentionDays
// ---------------------------------------------------------------------
[Fact]
public void Threshold_UsesAuditLogOptionsRetentionDays()
{
// The actor computes the threshold from AuditLogOptions.RetentionDays;
// assert the enumerator received a threshold whose value is in the
// expected window (today - retentionDays) rather than DateTime.MinValue
// or some other accidental default. We use a non-default retention
// (30 days) so the assertion isn't satisfied by the 365 default.
var repo = new RecordingRepo();
CreateActor(
repo,
FastTickOptions(),
auditOptions: new AuditLogOptions { RetentionDays = 30 });
AwaitAssert(
() => Assert.True(repo.ThresholdQueries.Count >= 1),
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
var threshold = repo.ThresholdQueries[0];
var expected = DateTime.UtcNow - TimeSpan.FromDays(30);
// 1-minute slack covers test-thread scheduling jitter between the
// tick firing and the assertion running.
Assert.True(
Math.Abs((threshold - expected).TotalMinutes) < 1.0,
$"threshold {threshold:o} should be within 1 minute of {expected:o}");
}
}
@@ -0,0 +1,98 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.AuditLog;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Bundle E (M6-T9) coverage for the central-side payload-filter redactor
/// failure bridge. M5 wired the SITE bridge
/// (<c>HealthMetricsAuditRedactionFailureCounter</c>) that pushes increments
/// into the site health report; M6 mirrors that with
/// <see cref="CentralAuditRedactionFailureCounter"/> so the same payload
/// filter — when it runs on the central writer paths — surfaces failures on
/// the central <see cref="AuditCentralHealthSnapshot"/>.
/// </summary>
public class CentralAuditRedactionFailureCounterTests : TestKit
{
[Fact]
public void Increment_Routes_To_Snapshot()
{
var snapshot = new AuditCentralHealthSnapshot();
var counter = new CentralAuditRedactionFailureCounter(snapshot);
counter.Increment();
counter.Increment();
counter.Increment();
Assert.Equal(3, snapshot.AuditRedactionFailure);
}
[Fact]
public void Construction_With_Null_Snapshot_Throws()
{
Assert.Throws<ArgumentNullException>(
() => new CentralAuditRedactionFailureCounter(null!));
}
[Fact]
public void AddAuditLogCentralMaintenance_Replaces_IAuditRedactionFailureCounter_With_CentralImpl()
{
// AddAuditLog registers NoOp; AddAuditLogCentralMaintenance is the
// override path. The replaced binding MUST resolve to the central
// bridge — a site host that wires AddAuditLogHealthMetricsBridge
// instead would resolve to the site bridge (covered in
// AddAuditLogTests).
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
})
.Build();
var services = new ServiceCollection();
services.AddSingleton<ILoggerFactory, NullLoggerFactory>();
services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
// AuditCentralHealthSnapshot no longer takes a tracker dependency —
// the tracker is constructed later by the Akka bootstrap because its
// ctor needs an ActorSystem (not a DI-resolvable singleton). The
// snapshot itself composes purely from primitives.
services.AddAuditLog(config);
services.AddAuditLogCentralMaintenance(config);
using var provider = services.BuildServiceProvider();
var counter = provider.GetRequiredService<IAuditRedactionFailureCounter>();
Assert.IsType<CentralAuditRedactionFailureCounter>(counter);
}
[Fact]
public void AddAuditLog_Default_IAuditRedactionFailureCounter_Is_NoOp()
{
// Sanity check: without AddAuditLogCentralMaintenance the default
// remains the NoOp from M5 — the central bridge only takes effect
// when the central-only registration runs.
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
})
.Build();
var services = new ServiceCollection();
services.AddSingleton<ILoggerFactory, NullLoggerFactory>();
services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
services.AddAuditLog(config);
using var provider = services.BuildServiceProvider();
var counter = provider.GetRequiredService<IAuditRedactionFailureCounter>();
Assert.IsType<NoOpAuditRedactionFailureCounter>(counter);
}
}
@@ -0,0 +1,170 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
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;
/// <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>());
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
}
/// <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
// the per-site stalled state is fed in via ApplyStalled — production
// wires that to a SiteAuditTelemetryStalledTracker, but the snapshot
// is testable in isolation against the same Apply surface.
var snapshot = new AuditCentralHealthSnapshot();
Assert.Equal(0, snapshot.CentralAuditWriteFailures);
Assert.Equal(0, snapshot.AuditRedactionFailure);
Assert.Empty(snapshot.SiteAuditTelemetryStalled);
((ICentralAuditWriteFailureCounter)snapshot).Increment();
((ICentralAuditWriteFailureCounter)snapshot).Increment();
((ZB.MOM.WW.ScadaBridge.AuditLog.Payload.IAuditRedactionFailureCounter)snapshot).Increment();
// Wire the tracker so an EventStream publish reaches the snapshot.
// The tracker pushes into the snapshot's ApplyStalled when given
// the snapshot in its ctor; the tracker also keeps its own latch,
// but the snapshot read surface is what the central UI reads.
using var tracker = new SiteAuditTelemetryStalledTracker(Sys, snapshot);
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 Snapshot_Empty_OnConstruction()
{
// Sanity: the snapshot's three properties start at their zero values
// before any writer or stalled-event publication.
var snapshot = new AuditCentralHealthSnapshot();
Assert.Equal(0, snapshot.CentralAuditWriteFailures);
Assert.Equal(0, snapshot.AuditRedactionFailure);
Assert.Empty(snapshot.SiteAuditTelemetryStalled);
}
}
@@ -0,0 +1,213 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// M4 Bundle B1 — unit tests for <see cref="CentralAuditWriter"/>, the
/// central-only direct-write implementation of <see cref="ICentralAuditWriter"/>.
/// The writer is a thin wrapper around
/// <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/>: it stamps
/// <see cref="AuditEvent.IngestedAtUtc"/>, resolves the (scoped) repository
/// from a fresh DI scope per call, and swallows any thrown exception —
/// audit-write failures NEVER abort the user-facing action (alog.md §13).
/// </summary>
public class CentralAuditWriterTests
{
private static AuditEvent NewEvent(Guid? eventId = null) => new()
{
EventId = eventId ?? Guid.NewGuid(),
OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
Channel = AuditChannel.Notification,
Kind = AuditKind.NotifyDeliver,
Status = AuditStatus.Attempted,
CorrelationId = Guid.NewGuid(),
Target = "ops-team",
};
private static (CentralAuditWriter writer, IAuditLogRepository repo) BuildWriter()
{
var repo = Substitute.For<IAuditLogRepository>();
var services = new ServiceCollection();
services.AddScoped(_ => repo);
var provider = services.BuildServiceProvider();
return (new CentralAuditWriter(provider, NullLogger<CentralAuditWriter>.Instance), repo);
}
[Fact]
public async Task WriteAsync_PassesEvent_To_InsertIfNotExistsAsync()
{
var (writer, repo) = BuildWriter();
var evt = NewEvent();
await writer.WriteAsync(evt);
await repo.Received(1).InsertIfNotExistsAsync(
Arg.Is<AuditEvent>(e => e.EventId == evt.EventId),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task WriteAsync_Stamps_IngestedAtUtc_Before_Insert()
{
var (writer, repo) = BuildWriter();
var before = DateTime.UtcNow;
await writer.WriteAsync(NewEvent());
var after = DateTime.UtcNow;
await repo.Received(1).InsertIfNotExistsAsync(
Arg.Is<AuditEvent>(e =>
e.IngestedAtUtc != null &&
e.IngestedAtUtc >= before &&
e.IngestedAtUtc <= after),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task WriteAsync_Repository_Throws_DoesNotPropagate()
{
var repo = Substitute.For<IAuditLogRepository>();
repo.InsertIfNotExistsAsync(Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("db down"));
var services = new ServiceCollection();
services.AddScoped(_ => repo);
var provider = services.BuildServiceProvider();
var writer = new CentralAuditWriter(provider, NullLogger<CentralAuditWriter>.Instance);
// Must not throw — audit failure NEVER aborts the user-facing action.
await writer.WriteAsync(NewEvent());
}
[Fact]
public async Task WriteAsync_Resolves_Repository_PerCall_From_Fresh_Scope()
{
// Counting factory: every scope opening should resolve a new repo
// (scoped lifetime). We assert at least two distinct instances
// across two WriteAsync calls.
var instances = new List<IAuditLogRepository>();
var services = new ServiceCollection();
services.AddScoped<IAuditLogRepository>(_ =>
{
var r = Substitute.For<IAuditLogRepository>();
instances.Add(r);
return r;
});
var provider = services.BuildServiceProvider();
var writer = new CentralAuditWriter(provider, NullLogger<CentralAuditWriter>.Instance);
await writer.WriteAsync(NewEvent());
await writer.WriteAsync(NewEvent());
Assert.Equal(2, instances.Count);
Assert.NotSame(instances[0], instances[1]);
}
[Fact]
public void Constructor_NullServices_Throws()
{
Assert.Throws<ArgumentNullException>(
() => new CentralAuditWriter(null!, NullLogger<CentralAuditWriter>.Instance));
}
[Fact]
public void Constructor_NullLogger_Throws()
{
var services = new ServiceCollection().BuildServiceProvider();
Assert.Throws<ArgumentNullException>(
() => new CentralAuditWriter(services, null!));
}
// ----- SourceNode stamping (Task 12) ----- //
private static (CentralAuditWriter writer, IAuditLogRepository repo) BuildWriterWithIdentity(
INodeIdentityProvider? nodeIdentity)
{
var repo = Substitute.For<IAuditLogRepository>();
var services = new ServiceCollection();
services.AddScoped(_ => repo);
var provider = services.BuildServiceProvider();
var writer = new CentralAuditWriter(
provider,
NullLogger<CentralAuditWriter>.Instance,
filter: null,
failureCounter: null,
nodeIdentity: nodeIdentity);
return (writer, repo);
}
[Fact]
public async Task WriteAsync_StampsSourceNodeFromProvider_WhenEventHasNone()
{
var (writer, repo) = BuildWriterWithIdentity(new FakeNodeIdentityProvider("central-a"));
await writer.WriteAsync(NewEvent());
await repo.Received(1).InsertIfNotExistsAsync(
Arg.Is<AuditEvent>(e => e.SourceNode == "central-a"),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task WriteAsync_PreservesCallerProvidedSourceNode()
{
var (writer, repo) = BuildWriterWithIdentity(new FakeNodeIdentityProvider("central-a"));
var evt = NewEvent() with { SourceNode = "central-b" };
await writer.WriteAsync(evt);
await repo.Received(1).InsertIfNotExistsAsync(
Arg.Is<AuditEvent>(e => e.SourceNode == "central-b"),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task WriteAsync_LeavesSourceNodeNull_WhenProviderReturnsNull()
{
var (writer, repo) = BuildWriterWithIdentity(new FakeNodeIdentityProvider(nodeName: null));
await writer.WriteAsync(NewEvent());
await repo.Received(1).InsertIfNotExistsAsync(
Arg.Is<AuditEvent>(e => e.SourceNode == null),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task WriteAsync_PassesThroughCallerSourceNode_WhenNoProviderInjected()
{
// Locks the back-compat contract for the optional `nodeIdentity = null`
// ctor parameter: when no provider is wired (e.g. legacy M4 test
// composition roots), the writer must not stamp — caller value passes
// through unmodified. Distinct code path from
// "provider supplied, returns null", which the test above covers.
var (writer, repo) = BuildWriterWithIdentity(nodeIdentity: null);
await writer.WriteAsync(NewEvent() with { SourceNode = "node-z" });
await repo.Received(1).InsertIfNotExistsAsync(
Arg.Is<AuditEvent>(e => e.SourceNode == "node-z"),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task WriteAsync_LeavesSourceNodeNull_WhenNoProviderInjected()
{
// Same back-compat contract for the null-caller-null-provider case.
var (writer, repo) = BuildWriterWithIdentity(nodeIdentity: null);
await writer.WriteAsync(NewEvent());
await repo.Received(1).InsertIfNotExistsAsync(
Arg.Is<AuditEvent>(e => e.SourceNode == null),
Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,453 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Bundle B (M6-T3) tests for <see cref="SiteAuditReconciliationActor"/>. Most
/// tests substitute the <see cref="IAuditLogRepository"/> with an in-memory
/// recording stub so the actor's tick / cursor / stalled state machinery can
/// be exercised in milliseconds without an MSSQL container. The duplicate /
/// idempotency assertion uses the real <see cref="AuditLogRepository"/> against
/// the <see cref="MsSqlMigrationFixture"/> so we verify InsertIfNotExistsAsync
/// actually swallows duplicate-key collisions (the M2 Bundle A race-fix the
/// reconciliation puller depends on).
/// </summary>
public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public SiteAuditReconciliationActorTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
private static AuditEvent NewEvent(
string siteId,
DateTime? occurredAt = null,
Guid? id = null) => new()
{
EventId = id ?? Guid.NewGuid(),
OccurredAtUtc = occurredAt ?? new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
SourceSiteId = siteId,
};
private static SiteAuditReconciliationOptions FastTickOptions(
int batchSize = 256,
int stalledAfter = 2) =>
new()
{
// 100 ms tick keeps each test under a second. AwaitAssert covers
// schedule jitter so a 100 ms tick has up to ~3 s to fire.
ReconciliationIntervalSeconds = 300,
ReconciliationIntervalOverride = TimeSpan.FromMilliseconds(100),
BatchSize = batchSize,
StalledAfterNonDrainingCycles = stalledAfter,
};
/// <summary>
/// In-memory recording stub used for non-MSSQL tests. Captures every
/// <see cref="InsertIfNotExistsAsync"/> call AND deduplicates on
/// <see cref="AuditEvent.EventId"/> so duplicate-handling assertions don't
/// need a real database for the simple cases.
/// </summary>
private sealed class RecordingRepo : IAuditLogRepository
{
public List<AuditEvent> Inserted { get; } = new();
private readonly HashSet<Guid> _seen = new();
public int InsertCallCount { get; private set; }
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
{
InsertCallCount++;
if (_seen.Add(evt.EventId))
{
Inserted.Add(evt);
}
return Task.CompletedTask;
}
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<AuditEvent>>(Inserted);
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>());
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
}
/// <summary>
/// In-memory enumerator returning a static list of sites.
/// </summary>
private sealed class StaticEnumerator : ISiteEnumerator
{
private readonly IReadOnlyList<SiteEntry> _sites;
public StaticEnumerator(params SiteEntry[] sites) => _sites = sites;
public Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default) =>
Task.FromResult(_sites);
}
/// <summary>
/// Scripted pull client — returns the next queued response for the site
/// on each call, looping the last entry if the queue is exhausted. Also
/// records every invocation so tests can assert call counts + arguments.
/// </summary>
private sealed class ScriptedPullClient : IPullAuditEventsClient
{
public List<(string SiteId, DateTime SinceUtc, int BatchSize)> Calls { get; } = new();
private readonly Dictionary<string, Queue<PullAuditEventsResponse>> _scripted = new();
private readonly Dictionary<string, Exception> _throwOnSite = new();
public ScriptedPullClient Script(string siteId, params PullAuditEventsResponse[] responses)
{
_scripted[siteId] = new Queue<PullAuditEventsResponse>(responses);
return this;
}
public ScriptedPullClient ThrowFor(string siteId, Exception ex)
{
_throwOnSite[siteId] = ex;
return this;
}
public Task<PullAuditEventsResponse> PullAsync(
string siteId, DateTime sinceUtc, int batchSize, CancellationToken ct)
{
Calls.Add((siteId, sinceUtc, batchSize));
if (_throwOnSite.TryGetValue(siteId, out var ex))
{
throw ex;
}
if (_scripted.TryGetValue(siteId, out var queue) && queue.Count > 0)
{
return Task.FromResult(queue.Dequeue());
}
return Task.FromResult(
new PullAuditEventsResponse(Array.Empty<AuditEvent>(), MoreAvailable: false));
}
}
private IServiceProvider BuildScopedProvider(IAuditLogRepository repo)
{
var services = new ServiceCollection();
// The actor opens a scope per tick and resolves IAuditLogRepository
// from that scope; registering as scoped mirrors how
// AddConfigurationDatabase wires the real repository.
services.AddScoped(_ => repo);
return services.BuildServiceProvider();
}
private IActorRef CreateActor(
ISiteEnumerator sites,
IPullAuditEventsClient client,
IAuditLogRepository repo,
SiteAuditReconciliationOptions options)
{
var sp = BuildScopedProvider(repo);
return Sys.ActorOf(Props.Create(() => new SiteAuditReconciliationActor(
sites,
client,
sp,
Options.Create(options),
NullLogger<SiteAuditReconciliationActor>.Instance)));
}
/// <summary>
/// Subscribes to the EventStream and collects every
/// <see cref="SiteAuditTelemetryStalledChanged"/> publication into a list
/// the test can assert on. Uses a probe actor so the stream's
/// fire-and-forget delivery is observable from the test thread.
/// </summary>
private (Akka.TestKit.TestProbe Probe, List<SiteAuditTelemetryStalledChanged> Captured) SubscribeStalled()
{
var probe = CreateTestProbe();
Sys.EventStream.Subscribe(probe.Ref, typeof(SiteAuditTelemetryStalledChanged));
var captured = new List<SiteAuditTelemetryStalledChanged>();
return (probe, captured);
}
// ---------------------------------------------------------------------
// 1. Timer_Fires_OnConfiguredInterval
// ---------------------------------------------------------------------
[Fact]
public void Timer_Fires_OnConfiguredInterval()
{
var sites = new StaticEnumerator(new SiteEntry("siteA", "http://siteA:8083"));
var client = new ScriptedPullClient();
var repo = new RecordingRepo();
var opts = FastTickOptions();
CreateActor(sites, client, repo, opts);
// The first scheduled tick fires after `ReconciliationIntervalSeconds`,
// which is 0 for the test — Akka's scheduler still respects the
// ScheduleTellRepeatedlyCancelable contract that issues a Tell on the
// scheduler thread, so we await visible side effects (a PullAsync call)
// rather than racing on internal state.
AwaitAssert(
() => Assert.True(client.Calls.Count >= 1, $"expected >= 1 pull call, got {client.Calls.Count}"),
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
}
// ---------------------------------------------------------------------
// 2. Tick_PullsFromEachKnownSite
// ---------------------------------------------------------------------
[Fact]
public void Tick_PullsFromEachKnownSite()
{
var sites = new StaticEnumerator(
new SiteEntry("siteA", "http://siteA:8083"),
new SiteEntry("siteB", "http://siteB:8083"));
var client = new ScriptedPullClient();
var repo = new RecordingRepo();
CreateActor(sites, client, repo, FastTickOptions());
AwaitAssert(() =>
{
Assert.Contains(client.Calls, c => c.SiteId == "siteA");
Assert.Contains(client.Calls, c => c.SiteId == "siteB");
},
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
}
// ---------------------------------------------------------------------
// 3. Tick_IngestEvents_ViaInsertIfNotExistsAsync
// ---------------------------------------------------------------------
[Fact]
public void Tick_IngestEvents_ViaInsertIfNotExistsAsync()
{
var sites = new StaticEnumerator(new SiteEntry("siteA", "http://siteA:8083"));
var e1 = NewEvent("siteA");
var e2 = NewEvent("siteA");
var client = new ScriptedPullClient().Script("siteA",
new PullAuditEventsResponse(new[] { e1, e2 }, MoreAvailable: false));
var repo = new RecordingRepo();
CreateActor(sites, client, repo, FastTickOptions());
AwaitAssert(() => Assert.Equal(2, repo.InsertCallCount),
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
Assert.Contains(repo.Inserted, e => e.EventId == e1.EventId);
Assert.Contains(repo.Inserted, e => e.EventId == e2.EventId);
}
// ---------------------------------------------------------------------
// 4. Tick_Duplicates_NotDoubleInserted (real MSSQL idempotency)
// ---------------------------------------------------------------------
private ScadaBridgeDbContext CreateContext() =>
new(new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer(_fixture.ConnectionString).Options);
[SkippableFact]
public async Task Tick_Duplicates_NotDoubleInserted()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = "bundle-b-" + Guid.NewGuid().ToString("N").Substring(0, 8);
var pre = NewEvent(siteId);
// Seed the row directly so the actor sees it already present when the
// pull returns it.
await using (var seedContext = CreateContext())
{
await new AuditLogRepository(seedContext).InsertIfNotExistsAsync(pre);
}
// Stack one new and the pre-existing row in the pull response. The
// second-pull script returns empty so the actor settles.
var fresh = NewEvent(siteId);
var sites = new StaticEnumerator(new SiteEntry(siteId, "http://x:8083"));
var client = new ScriptedPullClient().Script(siteId,
new PullAuditEventsResponse(new[] { pre, fresh }, MoreAvailable: false));
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
CreateActor(sites, client, repo, FastTickOptions());
// Wait for the actor to ingest both rows.
await Task.Delay(TimeSpan.FromSeconds(1));
AwaitAssert(() => Assert.True(client.Calls.Count >= 1),
duration: TimeSpan.FromSeconds(3));
// Even though the pull returned 2 events, only 1 fresh row should
// exist in MSSQL alongside the pre-existing one — InsertIfNotExistsAsync
// is first-write-wins on EventId.
await using var read = CreateContext();
var rows = await read.Set<AuditEvent>()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
Assert.Equal(2, rows.Count);
Assert.Contains(rows, r => r.EventId == pre.EventId);
Assert.Contains(rows, r => r.EventId == fresh.EventId);
}
// ---------------------------------------------------------------------
// 5. Cursor_Advances_ToMaxOccurredAtUtc
// ---------------------------------------------------------------------
[Fact]
public void Cursor_Advances_ToMaxOccurredAtUtc()
{
var sites = new StaticEnumerator(new SiteEntry("siteA", "http://siteA:8083"));
var t1 = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
var t2 = new DateTime(2026, 5, 20, 10, 1, 0, DateTimeKind.Utc);
var t3 = new DateTime(2026, 5, 20, 10, 2, 0, DateTimeKind.Utc);
var e1 = NewEvent("siteA", t1);
var e2 = NewEvent("siteA", t2);
var e3 = NewEvent("siteA", t3);
// First pull returns three events with t1, t2, t3. Subsequent pulls
// return empty — but the test asserts the SECOND pull's since argument
// is t3 (the max OccurredAtUtc from the first pull).
var client = new ScriptedPullClient().Script("siteA",
new PullAuditEventsResponse(new[] { e1, e2, e3 }, MoreAvailable: false));
var repo = new RecordingRepo();
CreateActor(sites, client, repo, FastTickOptions());
// Wait until we have at least two pulls — the second one must use t3
// as its `since` argument because that was the max OccurredAtUtc in
// the first response.
AwaitAssert(() => Assert.True(client.Calls.Count >= 2,
$"need at least 2 pulls to assert cursor advancement, got {client.Calls.Count}"),
duration: TimeSpan.FromSeconds(5),
interval: TimeSpan.FromMilliseconds(50));
Assert.Equal(DateTime.MinValue, client.Calls[0].SinceUtc);
Assert.Equal(t3, client.Calls[1].SinceUtc);
}
// ---------------------------------------------------------------------
// 6. Tick_OneSiteThrows_OtherSitesStillProcessed
// ---------------------------------------------------------------------
[Fact]
public void Tick_OneSiteThrows_OtherSitesStillProcessed()
{
var sites = new StaticEnumerator(
new SiteEntry("siteA", "http://siteA:8083"),
new SiteEntry("siteB", "http://siteB:8083"));
var bEvent = NewEvent("siteB");
var client = new ScriptedPullClient()
.ThrowFor("siteA", new InvalidOperationException("simulated transport failure"))
.Script("siteB",
new PullAuditEventsResponse(new[] { bEvent }, MoreAvailable: false));
var repo = new RecordingRepo();
CreateActor(sites, client, repo, FastTickOptions());
AwaitAssert(() =>
{
Assert.Contains(client.Calls, c => c.SiteId == "siteA");
Assert.Contains(repo.Inserted, e => e.EventId == bEvent.EventId);
},
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
}
// ---------------------------------------------------------------------
// 7. StalledDetection_TwoConsecutiveNonDrainingCycles_PublishesStalledTrue
// ---------------------------------------------------------------------
[Fact]
public void StalledDetection_TwoConsecutiveNonDrainingCycles_PublishesStalledTrue()
{
var sites = new StaticEnumerator(new SiteEntry("siteA", "http://siteA:8083"));
// Two scripted responses that each return events AND MoreAvailable=true
// — the second pull triggers the stalled transition.
var batch1 = Enumerable.Range(0, 3).Select(_ => NewEvent("siteA")).ToArray();
var batch2 = Enumerable.Range(0, 3).Select(_ => NewEvent("siteA")).ToArray();
var client = new ScriptedPullClient().Script("siteA",
new PullAuditEventsResponse(batch1, MoreAvailable: true),
new PullAuditEventsResponse(batch2, MoreAvailable: true));
var repo = new RecordingRepo();
var (probe, _) = SubscribeStalled();
CreateActor(sites, client, repo, FastTickOptions(stalledAfter: 2));
// Expect Stalled=true after the second non-draining tick. The probe
// waits with its own timeout (a few seconds gives the 0 s repeat
// interval ample slack).
var msg = probe.ExpectMsg<SiteAuditTelemetryStalledChanged>(TimeSpan.FromSeconds(5));
Assert.Equal("siteA", msg.SiteId);
Assert.True(msg.Stalled);
}
// ---------------------------------------------------------------------
// 8. StalledDetection_DrainingCycle_PublishesStalledFalse
// ---------------------------------------------------------------------
[Fact]
public void StalledDetection_DrainingCycle_PublishesStalledFalse()
{
var sites = new StaticEnumerator(new SiteEntry("siteA", "http://siteA:8083"));
// Two non-draining responses get the actor into Stalled=true, then a
// draining response (events but MoreAvailable=false) flips it back.
var batch1 = Enumerable.Range(0, 3).Select(_ => NewEvent("siteA")).ToArray();
var batch2 = Enumerable.Range(0, 3).Select(_ => NewEvent("siteA")).ToArray();
var batch3 = Enumerable.Range(0, 3).Select(_ => NewEvent("siteA")).ToArray();
var client = new ScriptedPullClient().Script("siteA",
new PullAuditEventsResponse(batch1, MoreAvailable: true),
new PullAuditEventsResponse(batch2, MoreAvailable: true),
new PullAuditEventsResponse(batch3, MoreAvailable: false));
var repo = new RecordingRepo();
var (probe, _) = SubscribeStalled();
CreateActor(sites, client, repo, FastTickOptions(stalledAfter: 2));
// First publication is the stalled=true transition; second is the
// back-to-draining flip. The actor publishes ONLY on transitions so we
// expect exactly these two messages in order.
var first = probe.ExpectMsg<SiteAuditTelemetryStalledChanged>(TimeSpan.FromSeconds(5));
Assert.True(first.Stalled);
var second = probe.ExpectMsg<SiteAuditTelemetryStalledChanged>(TimeSpan.FromSeconds(5));
Assert.False(second.Stalled);
Assert.Equal("siteA", second.SiteId);
}
}
@@ -0,0 +1,116 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Bundle E (M6-T7) tests for <see cref="SiteAuditTelemetryStalledTracker"/>.
/// The tracker subscribes to the actor system's EventStream for
/// <see cref="SiteAuditTelemetryStalledChanged"/> publications and maintains a
/// per-site latch the central health surface can read. Since reconciliation is
/// central-driven, the "stalled" state semantically belongs to central — not
/// to the per-site <see cref="ZB.MOM.WW.ScadaBridge.Commons.Messages.Health.SiteHealthReport"/>
/// payload (which the site itself emits). The tracker therefore lives as a
/// central singleton, not on the site health collector.
/// </summary>
public class SiteAuditTelemetryStalledTrackerTests : TestKit
{
/// <summary>
/// Helper: publishes a stalled-changed event on the actor system's
/// EventStream and waits a moment for the tracker's subscribe callback to
/// run. AwaitAssert avoids racing on the stream's async fan-out.
/// </summary>
private void PublishAndWait(SiteAuditTelemetryStalledTracker tracker, SiteAuditTelemetryStalledChanged evt)
{
Sys.EventStream.Publish(evt);
AwaitAssert(
() =>
{
var snapshot = tracker.Snapshot();
Assert.True(snapshot.TryGetValue(evt.SiteId, out var stalled),
$"tracker did not record event for {evt.SiteId}");
Assert.Equal(evt.Stalled, stalled);
},
duration: TimeSpan.FromSeconds(2),
interval: TimeSpan.FromMilliseconds(20));
}
[Fact]
public void Initial_Snapshot_IsEmpty()
{
using var tracker = new SiteAuditTelemetryStalledTracker(Sys);
var snapshot = tracker.Snapshot();
Assert.Empty(snapshot);
}
[Fact]
public void StalledTrue_Event_TrackerReports_Stalled()
{
using var tracker = new SiteAuditTelemetryStalledTracker(Sys);
PublishAndWait(tracker, new SiteAuditTelemetryStalledChanged("siteA", Stalled: true));
var snapshot = tracker.Snapshot();
Assert.True(snapshot["siteA"]);
}
[Fact]
public void StalledFalse_Event_TrackerReports_NotStalled()
{
using var tracker = new SiteAuditTelemetryStalledTracker(Sys);
// First flip the site into stalled so the false transition has a
// prior value to overwrite — mirrors how the reconciliation actor
// only publishes false after a true.
PublishAndWait(tracker, new SiteAuditTelemetryStalledChanged("siteA", Stalled: true));
PublishAndWait(tracker, new SiteAuditTelemetryStalledChanged("siteA", Stalled: false));
var snapshot = tracker.Snapshot();
Assert.False(snapshot["siteA"]);
}
[Fact]
public void Multiple_Sites_Tracked_Independently()
{
using var tracker = new SiteAuditTelemetryStalledTracker(Sys);
PublishAndWait(tracker, new SiteAuditTelemetryStalledChanged("siteA", Stalled: true));
PublishAndWait(tracker, new SiteAuditTelemetryStalledChanged("siteB", Stalled: false));
PublishAndWait(tracker, new SiteAuditTelemetryStalledChanged("siteC", Stalled: true));
var snapshot = tracker.Snapshot();
Assert.Equal(3, snapshot.Count);
Assert.True(snapshot["siteA"]);
Assert.False(snapshot["siteB"]);
Assert.True(snapshot["siteC"]);
}
[Fact]
public void Constructor_With_Null_ActorSystem_Throws()
{
Assert.Throws<ArgumentNullException>(
() => new SiteAuditTelemetryStalledTracker((ActorSystem)null!));
}
[Fact]
public void Dispose_Unsubscribes_From_EventStream()
{
var tracker = new SiteAuditTelemetryStalledTracker(Sys);
PublishAndWait(tracker, new SiteAuditTelemetryStalledChanged("siteA", Stalled: true));
tracker.Dispose();
// After dispose any further events are ignored — the snapshot
// reflects the last known state at dispose time.
Sys.EventStream.Publish(new SiteAuditTelemetryStalledChanged("siteA", Stalled: false));
// Give the stream a moment in case the unsubscribe is racey; the
// assertion is that siteA stays at true.
Thread.Sleep(50);
Assert.True(tracker.Snapshot()["siteA"]);
}
}
@@ -0,0 +1,222 @@
using System.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration;
/// <summary>
/// Bundle D (M5-T8) tests for hot-reloadable <see cref="AuditLogOptions"/>
/// binding. The first test pins the JSON-realistic binding shape end-to-end
/// (scalars, lists, per-target overrides) so accidental drift in the section
/// layout breaks the build. The second test exercises the live hot-reload
/// path: a <see cref="DefaultAuditPayloadFilter"/> backed by a mutable
/// <see cref="IOptionsMonitor{TOptions}"/> must respond to config changes on
/// the very next event, with both cap-bytes and the regex-cache invalidation
/// flowing through without a restart.
/// </summary>
/// <remarks>
/// Distinct from <see cref="AuditLogOptionsTests"/> (M1-T9) which covered
/// section binding + validator failures via single-key in-memory config — those
/// tests exist; these add (a) end-to-end binding from a realistic JSON literal
/// and (b) the hot-reload behavioural contract the M5-T8 spec calls out.
/// </remarks>
public class AuditLogOptionsBindingTests
{
[Fact]
public void AuditLog_Section_Binds_AllFields()
{
const string json = """
{
"AuditLog": {
"DefaultCapBytes": 4096,
"ErrorCapBytes": 32768,
"HeaderRedactList": ["Authorization", "Custom-Token"],
"GlobalBodyRedactors": ["\"password\":\\s*\"[^\"]*\""],
"PerTargetOverrides": {
"myconnection": {
"CapBytes": 16384,
"AdditionalBodyRedactors": [],
"RedactSqlParamsMatching": "@token|@secret"
}
},
"RetentionDays": 180,
"InboundMaxBytes": 524288
}
}
""";
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
var configuration = new ConfigurationBuilder()
.AddJsonStream(stream)
.Build();
var services = new ServiceCollection();
services.AddAuditLog(configuration);
using var provider = services.BuildServiceProvider();
var opts = provider.GetRequiredService<IOptions<AuditLogOptions>>().Value;
// Scalars.
Assert.Equal(4096, opts.DefaultCapBytes);
Assert.Equal(32768, opts.ErrorCapBytes);
Assert.Equal(180, opts.RetentionDays);
Assert.Equal(524_288, opts.InboundMaxBytes);
// HeaderRedactList: the Microsoft.Extensions.Configuration list binder
// APPENDS to the default list, so we assert containment rather than
// exact equality (see M1-T9 AuditLogOptionsTests for the rationale).
Assert.Contains("Authorization", opts.HeaderRedactList);
Assert.Contains("Custom-Token", opts.HeaderRedactList);
// GlobalBodyRedactors: pattern arrived intact, regex-escape sequences
// and all.
Assert.Contains("\"password\":\\s*\"[^\"]*\"", opts.GlobalBodyRedactors);
// PerTargetOverrides: keyed by connection name, each field bound.
Assert.True(opts.PerTargetOverrides.ContainsKey("myconnection"));
var ov = opts.PerTargetOverrides["myconnection"];
Assert.Equal(16384, ov.CapBytes);
// Microsoft.Extensions.Configuration JSON binder leaves an empty array
// null on a nullable List<T>; either null or empty is acceptable as
// "no additional redactors" — both result in zero patterns at use.
Assert.True(ov.AdditionalBodyRedactors is null || ov.AdditionalBodyRedactors.Count == 0);
Assert.Equal("@token|@secret", ov.RedactSqlParamsMatching);
}
[Fact]
public void Filter_Behavior_Updates_OnConfigReload()
{
// Start at the default cap (4096). A 5 KB body should be truncated;
// PayloadTruncated flips to true.
var initial = new AuditLogOptions { DefaultCapBytes = 4096 };
var monitor = new TestOptionsMonitor<AuditLogOptions>(initial);
var filter = new DefaultAuditPayloadFilter(
monitor,
NullLogger<DefaultAuditPayloadFilter>.Instance);
var body = new string('x', 5 * 1024);
var evt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
RequestSummary = body,
};
var resultBefore = filter.Apply(evt);
Assert.True(resultBefore.PayloadTruncated, "5KB body at 4096 cap must be truncated");
Assert.NotNull(resultBefore.RequestSummary);
Assert.True(Encoding.UTF8.GetByteCount(resultBefore.RequestSummary!) <= 4096);
// Reload: cap raised to 16384 — next event must NOT truncate. This is
// the M5-T8 contract: the filter sees the new value on the very next
// Apply, without process restart.
monitor.Set(new AuditLogOptions { DefaultCapBytes = 16384 });
var resultAfter = filter.Apply(evt);
Assert.False(resultAfter.PayloadTruncated, "5KB body at 16384 cap must NOT be truncated");
Assert.Equal(body, resultAfter.RequestSummary);
}
[Fact]
public void Filter_PicksUp_NewBodyRedactor_OnConfigReload()
{
// The regex cache is keyed by pattern string — a redactor added via
// config reload must compile + apply on the very next event without a
// process restart. Pre-reload: no redactor, hunter2 survives. After
// reload: hunter2 redacted.
var monitor = new TestOptionsMonitor<AuditLogOptions>(new AuditLogOptions());
var filter = new DefaultAuditPayloadFilter(
monitor,
NullLogger<DefaultAuditPayloadFilter>.Instance);
const string body = "{\"user\":\"alice\",\"password\":\"hunter2\"}";
var evt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
RequestSummary = body,
};
var before = filter.Apply(evt);
Assert.Contains("hunter2", before.RequestSummary!);
monitor.Set(new AuditLogOptions
{
GlobalBodyRedactors = new List<string> { "\"password\":\\s*\"[^\"]*\"" },
});
var after = filter.Apply(evt);
Assert.DoesNotContain("hunter2", after.RequestSummary!);
Assert.Contains("<redacted>", after.RequestSummary!);
}
/// <summary>
/// IOptionsMonitor test double — exposes a <see cref="Set"/> method that
/// updates the current value and fires registered OnChange callbacks.
/// Avoids depending on Microsoft.Extensions.Configuration's reload-token
/// plumbing, which is awkward to drive deterministically from xUnit.
/// </summary>
private sealed class TestOptionsMonitor<T> : IOptionsMonitor<T>
{
private T _current;
private readonly List<Action<T, string?>> _listeners = new();
public TestOptionsMonitor(T initial) => _current = initial;
public T CurrentValue => _current;
public T Get(string? name) => _current;
public IDisposable? OnChange(Action<T, string?> listener)
{
lock (_listeners)
{
_listeners.Add(listener);
}
return new Unsubscribe(_listeners, listener);
}
public void Set(T value)
{
_current = value;
Action<T, string?>[] snapshot;
lock (_listeners)
{
snapshot = _listeners.ToArray();
}
foreach (var l in snapshot)
{
l(_current, Options.DefaultName);
}
}
private sealed class Unsubscribe : IDisposable
{
private readonly List<Action<T, string?>> _listeners;
private readonly Action<T, string?> _listener;
public Unsubscribe(List<Action<T, string?>> listeners, Action<T, string?> listener)
{
_listeners = listeners;
_listener = listener;
}
public void Dispose()
{
lock (_listeners)
{
_listeners.Remove(_listener);
}
}
}
}
}
@@ -0,0 +1,167 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration;
/// <summary>
/// Task 9 (Bundle E): <see cref="AuditLogOptions"/> binding + validator
/// behavior. The validator enforces invariants used by M2+ writers
/// (per <c>docs/plans/2026-05-20-auditlog-m1-foundation.md</c>):
/// <c>DefaultCapBytes &gt; 0</c>, <c>ErrorCapBytes &gt;= DefaultCapBytes</c>,
/// <c>RetentionDays in [30, 3650]</c>. Header-redact defaults match the
/// design doc (alog.md §6): Authorization, X-Api-Key, Cookie, Set-Cookie.
/// </summary>
public class AuditLogOptionsTests
{
private static IOptions<AuditLogOptions> BuildOptions(Dictionary<string, string?> config)
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(config)
.Build();
var services = new ServiceCollection();
services.AddAuditLog(configuration);
return services.BuildServiceProvider().GetRequiredService<IOptions<AuditLogOptions>>();
}
[Fact]
public void ValidBinding_PopulatesAllScalarFields()
{
var opts = BuildOptions(new Dictionary<string, string?>
{
["AuditLog:DefaultCapBytes"] = "4096",
["AuditLog:ErrorCapBytes"] = "32768",
["AuditLog:RetentionDays"] = "180",
}).Value;
Assert.Equal(4096, opts.DefaultCapBytes);
Assert.Equal(32768, opts.ErrorCapBytes);
Assert.Equal(180, opts.RetentionDays);
}
[Fact]
public void DefaultsAreReasonable_WhenSectionEmpty()
{
var opts = BuildOptions(new Dictionary<string, string?>()).Value;
Assert.Equal(8192, opts.DefaultCapBytes);
Assert.Equal(65536, opts.ErrorCapBytes);
Assert.Equal(365, opts.RetentionDays);
Assert.Contains("Authorization", opts.HeaderRedactList);
Assert.Contains("X-Api-Key", opts.HeaderRedactList);
Assert.Contains("Cookie", opts.HeaderRedactList);
Assert.Contains("Set-Cookie", opts.HeaderRedactList);
Assert.Empty(opts.GlobalBodyRedactors);
Assert.Empty(opts.PerTargetOverrides);
}
[Fact]
public void HeaderRedactList_BindsFromConfig_AppendsToDefaults()
{
// Microsoft.Extensions.Configuration's collection binder appends to a
// defaulted list (it does not replace it), so config-supplied entries
// augment the built-in redact list rather than overriding it. The
// built-in entries are the safety-net defaults documented on
// AuditLogOptions; supplying additional headers is the supported
// extension point.
var opts = BuildOptions(new Dictionary<string, string?>
{
["AuditLog:HeaderRedactList:0"] = "X-Custom-Auth",
["AuditLog:HeaderRedactList:1"] = "X-Tenant-Id",
}).Value;
Assert.Contains("X-Custom-Auth", opts.HeaderRedactList);
Assert.Contains("X-Tenant-Id", opts.HeaderRedactList);
Assert.Contains("Authorization", opts.HeaderRedactList);
Assert.Contains("X-Api-Key", opts.HeaderRedactList);
}
[Fact]
public void PerTargetOverrides_BindsFromConfig()
{
var opts = BuildOptions(new Dictionary<string, string?>
{
["AuditLog:PerTargetOverrides:CRM:CapBytes"] = "16384",
["AuditLog:PerTargetOverrides:CRM:AdditionalBodyRedactors:0"] = @"\d{16}",
}).Value;
Assert.True(opts.PerTargetOverrides.ContainsKey("CRM"));
var crm = opts.PerTargetOverrides["CRM"];
Assert.Equal(16384, crm.CapBytes);
Assert.NotNull(crm.AdditionalBodyRedactors);
Assert.Contains(@"\d{16}", crm.AdditionalBodyRedactors!);
}
[Fact]
public void InvalidDefaultCapBytes_FailsValidation()
{
var result = new AuditLogOptionsValidator().Validate(
Options.DefaultName,
new AuditLogOptions { DefaultCapBytes = 0 });
Assert.True(result.Failed);
Assert.Contains("DefaultCapBytes", result.FailureMessage);
}
[Fact]
public void InvalidErrorCapBytes_FailsValidation()
{
var result = new AuditLogOptionsValidator().Validate(
Options.DefaultName,
new AuditLogOptions { DefaultCapBytes = 1000, ErrorCapBytes = 100 });
Assert.True(result.Failed);
Assert.Contains("ErrorCapBytes", result.FailureMessage);
}
[Fact]
public void RetentionDaysBelowMinimum_FailsValidation()
{
var result = new AuditLogOptionsValidator().Validate(
Options.DefaultName,
new AuditLogOptions { RetentionDays = 0 });
Assert.True(result.Failed);
Assert.Contains("RetentionDays", result.FailureMessage);
}
[Fact]
public void RetentionDaysAboveMaximum_FailsValidation()
{
var result = new AuditLogOptionsValidator().Validate(
Options.DefaultName,
new AuditLogOptions { RetentionDays = 3651 });
Assert.True(result.Failed);
Assert.Contains("RetentionDays", result.FailureMessage);
}
[Fact]
public void DefaultOptions_PassValidation()
{
var result = new AuditLogOptionsValidator().Validate(
Options.DefaultName,
new AuditLogOptions());
Assert.True(result.Succeeded, result.FailureMessage);
}
[Fact]
public void InvalidRetention_BoundViaConfig_RejectedOnValueAccess()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["AuditLog:RetentionDays"] = "0",
})
.Build();
var services = new ServiceCollection();
services.AddAuditLog(configuration);
var provider = services.BuildServiceProvider();
var opts = provider.GetRequiredService<IOptions<AuditLogOptions>>();
var ex = Assert.Throws<OptionsValidationException>(() => _ = opts.Value);
Assert.Contains("RetentionDays", ex.Message);
}
}
@@ -0,0 +1,53 @@
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration;
/// <summary>
/// Task 1 of <c>docs/plans/2026-05-23-inbound-api-full-response-audit.md</c>:
/// pins the <see cref="AuditLogOptions.InboundMaxBytes"/> default to 1 MiB and
/// the validator bounds to <c>[8 KiB, 16 MiB]</c>. The inbound channel needs a
/// much larger ceiling than the 8 KiB / 64 KiB default/error caps that other
/// channels use, but unbounded would let any caller flood the central
/// <c>AuditLog</c> table with arbitrarily large bodies — hence the upper bound.
/// Companion to <see cref="AuditLogOptionsTests"/> which covers the existing
/// cap-bytes + retention invariants.
/// </summary>
public class AuditLogOptionsValidatorTests
{
[Fact]
public void Validate_InboundMaxBytes_DefaultOptions_IsOneMebibyte()
{
// The doc'd default per docs/plans/2026-05-23-inbound-api-full-response-audit-design.md
// is 1 048 576 bytes (1 MiB). Pin it so a config drift is a test failure,
// not a silent operational surprise.
var opts = new AuditLogOptions();
Assert.Equal(1_048_576, opts.InboundMaxBytes);
}
[Theory]
[InlineData(8_192)] // documented min
[InlineData(1_048_576)] // default
[InlineData(16_777_216)] // documented max
public void Validate_InboundMaxBytes_InRange_Passes(int value)
{
var validator = new AuditLogOptionsValidator();
var opts = new AuditLogOptions { InboundMaxBytes = value };
Assert.True(validator.Validate(null, opts).Succeeded);
}
[Theory]
[InlineData(0)]
[InlineData(8_191)]
[InlineData(16_777_217)]
[InlineData(int.MaxValue)]
public void Validate_InboundMaxBytes_OutOfRange_Fails(int value)
{
var validator = new AuditLogOptionsValidator();
var opts = new AuditLogOptions { InboundMaxBytes = value };
var result = validator.Validate(null, opts);
Assert.False(result.Succeeded);
Assert.Contains(
result.Failures!,
f => f.Contains(nameof(AuditLogOptions.InboundMaxBytes), StringComparison.Ordinal));
}
}
@@ -0,0 +1,477 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Messages;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using System.Net;
using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
/// <summary>
/// Audit Log #23 — M4 Bundle E (Task E4) cross-boundary safety suite verifying
/// the alog.md §13 contract: an always-throwing audit writer NEVER aborts the
/// user-facing action. Exercises every boundary that emits audit rows in M2,
/// M3, and M4:
///
/// <list type="bullet">
/// <item><description>External system sync call (M2 Bundle F).</description></item>
/// <item><description>External system cached call (M3 Bundle E).</description></item>
/// <item><description>Database sync write (M4 Bundle A).</description></item>
/// <item><description>Inbound API request (M4 Bundle D).</description></item>
/// <item><description>Notification dispatcher (M4 Bundle B).</description></item>
/// </list>
///
/// <para>
/// The site-local boundaries (ESG sync/cached, DB sync) take the always-throw
/// <see cref="ThrowingAuditWriter"/> in place of the production
/// <see cref="IAuditWriter"/>; the central boundaries (Inbound API,
/// Notification dispatcher) take the always-throw
/// <see cref="ThrowingCentralAuditWriter"/> in place of
/// <see cref="ICentralAuditWriter"/>. In each case the wrapped action's
/// original return value (or original exception) must still flow back to the
/// caller untouched.
/// </para>
/// </remarks>
public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public AuditWriteFailureSafetyTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
// ---------------------------------------------------------------------
// Always-throwing writer test doubles
// ---------------------------------------------------------------------
/// <summary>
/// Site-side <see cref="IAuditWriter"/> that ALWAYS throws on
/// <see cref="WriteAsync"/>. Used to verify that ESG / DB script-side
/// helpers swallow the throw and return their normal result to the script.
/// </summary>
private sealed class ThrowingAuditWriter : IAuditWriter
{
private int _attempts;
public int Attempts => Volatile.Read(ref _attempts);
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
Interlocked.Increment(ref _attempts);
return Task.FromException(new InvalidOperationException(
"test-only ThrowingAuditWriter — audit pipeline unavailable"));
}
}
/// <summary>
/// Central-side <see cref="ICentralAuditWriter"/> that ALWAYS throws on
/// <see cref="WriteAsync"/>. Used to verify Inbound API + Notification
/// dispatcher absorb audit-write failures rather than propagating them
/// into the response / state transition.
/// </summary>
private sealed class ThrowingCentralAuditWriter : ICentralAuditWriter
{
private int _attempts;
public int Attempts => Volatile.Read(ref _attempts);
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
Interlocked.Increment(ref _attempts);
throw new InvalidOperationException(
"test-only ThrowingCentralAuditWriter — audit subsystem unavailable");
}
}
/// <summary>
/// Site-side <see cref="ICachedCallTelemetryForwarder"/> that ALWAYS
/// throws on <see cref="ForwardAsync"/>. The cached-call helpers absorb
/// the throw and still return a valid <see cref="TrackedOperationId"/>.
/// </summary>
private sealed class ThrowingCachedForwarder : ICachedCallTelemetryForwarder
{
private int _attempts;
public int Attempts => Volatile.Read(ref _attempts);
public Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default)
{
Interlocked.Increment(ref _attempts);
return Task.FromException(new InvalidOperationException(
"test-only ThrowingCachedForwarder — telemetry pipeline unavailable"));
}
}
// ---------------------------------------------------------------------
// Test 1 — ESG sync call still returns the original ExternalCallResult.
// ---------------------------------------------------------------------
[Fact]
public async Task EsgSyncCall_BrokenAuditWriter_StillReturnsResult()
{
var client = Substitute.For<IExternalSystemClient>();
var expected = new ExternalCallResult(
Success: true,
ResponseJson: "{\"orderId\":42}",
ErrorMessage: null,
WasBuffered: false);
client.CallAsync(
"ERP", "GetOrder",
Arg.Any<IReadOnlyDictionary<string, object?>?>(),
Arg.Any<CancellationToken>())
.Returns(expected);
var writer = new ThrowingAuditWriter();
var helper = new ScriptRuntimeContext.ExternalSystemHelper(
client,
instanceName: "Plant.Pump42",
NullLogger.Instance,
Guid.NewGuid(),
auditWriter: writer,
siteId: "site-77",
sourceScript: "ScriptActor:Sync",
cachedForwarder: null);
var result = await helper.Call("ERP", "GetOrder");
Assert.Same(expected, result);
// Proof the audit writer was attempted — otherwise the test wouldn't
// actually exercise the safety contract.
Assert.True(writer.Attempts >= 1,
$"Expected audit writer to be invoked at least once; saw {writer.Attempts}.");
}
// ---------------------------------------------------------------------
// Test 2 — ESG cached call still returns a TrackedOperationId.
// ---------------------------------------------------------------------
[Fact]
public async Task EsgCachedCall_BrokenAuditWriter_StillReturnsTrackedOperationId()
{
var client = Substitute.For<IExternalSystemClient>();
// CachedCallAsync returns WasBuffered=true so the helper takes the
// S&F-deferred path — no immediate-terminal telemetry, which keeps the
// forwarder attempt count at exactly one (the CachedSubmit emission).
client.CachedCallAsync(
"ERP", "GetOrder",
Arg.Any<IReadOnlyDictionary<string, object?>?>(),
Arg.Any<string?>(),
Arg.Any<CancellationToken>(),
Arg.Any<TrackedOperationId?>())
.Returns(new ExternalCallResult(true, null, null, WasBuffered: true));
// BOTH the audit writer AND the cached forwarder throw — the
// CachedSubmit emission goes through the forwarder in production, so
// breaking only the writer wouldn't actually exercise the cached
// path's safety contract.
var writer = new ThrowingAuditWriter();
var forwarder = new ThrowingCachedForwarder();
var helper = new ScriptRuntimeContext.ExternalSystemHelper(
client,
instanceName: "Plant.Pump42",
NullLogger.Instance,
Guid.NewGuid(),
auditWriter: writer,
siteId: "site-77",
sourceScript: "ScriptActor:Cached",
cachedForwarder: forwarder);
var trackedId = await helper.CachedCall("ERP", "GetOrder");
// Non-default id materialised despite the forwarder failing.
Assert.NotEqual(default, trackedId);
Assert.True(forwarder.Attempts >= 1,
$"Expected cached forwarder to be invoked at least once; saw {forwarder.Attempts}.");
}
// ---------------------------------------------------------------------
// Test 3 — DB sync write still returns the rows-affected count.
// ---------------------------------------------------------------------
[Fact]
public async Task DbSyncWrite_BrokenAuditWriter_StillReturnsRowsAffected()
{
const string connectionName = "machineData";
const string instanceName = "Plant.Pump42";
using var keepAlive = new SqliteConnection(
"Data Source=k-safety-db;Mode=Memory;Cache=Shared");
keepAlive.Open();
// Schema + seed inside a unique in-memory DB.
var dbName = $"db-{Guid.NewGuid():N}";
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
using var dbKeepAlive = new SqliteConnection(connStr);
dbKeepAlive.Open();
using (var seed = dbKeepAlive.CreateCommand())
{
seed.CommandText =
"CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT NOT NULL);";
seed.ExecuteNonQuery();
}
var inner = new SqliteConnection(connStr);
inner.Open();
var gateway = Substitute.For<IDatabaseGateway>();
gateway.GetConnectionAsync(connectionName, Arg.Any<CancellationToken>())
.Returns(inner);
var writer = new ThrowingAuditWriter();
var helper = new ScriptRuntimeContext.DatabaseHelper(
gateway,
instanceName,
NullLogger.Instance,
Guid.NewGuid(),
auditWriter: writer,
siteId: "site-77",
sourceScript: "ScriptActor:Db",
cachedForwarder: null);
await using (var conn = await helper.Connection(connectionName))
await using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "INSERT INTO t (id, name) VALUES (1, 'safety')";
var rows = await cmd.ExecuteNonQueryAsync();
Assert.Equal(1, rows);
}
Assert.True(writer.Attempts >= 1,
$"Expected audit writer to be invoked at least once; saw {writer.Attempts}.");
}
// ---------------------------------------------------------------------
// Test 4 — Inbound API request still returns HTTP 200.
// ---------------------------------------------------------------------
[Fact]
public async Task InboundApi_BrokenAuditWriter_StillReturns200()
{
var writer = new ThrowingCentralAuditWriter();
using var host = await BuildInboundApiHostAsync(writer, endpointStatus: 200);
var client = host.GetTestClient();
var resp = await client.PostAsync(
"/api/echo",
new StringContent("{\"x\":1}", Encoding.UTF8, "application/json"));
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
Assert.True(writer.Attempts >= 1,
$"Expected central audit writer to be invoked at least once; saw {writer.Attempts}.");
}
// ---------------------------------------------------------------------
// Test 5 — Notification dispatcher still transitions to Delivered.
// ---------------------------------------------------------------------
[SkippableFact]
public async Task NotificationDispatch_BrokenAuditWriter_StillTransitionsToDelivered()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = "test-e4-safety-" + Guid.NewGuid().ToString("N").Substring(0, 8);
var notificationId = Guid.NewGuid();
await SeedSmtpConfigAsync();
await SeedNotificationAsync(notificationId, siteId);
var adapter = new SingleOutcomeAdapter(DeliveryOutcome.Success("ops@example.com"));
var serviceProvider = BuildNotificationDispatcherProvider(adapter);
var throwingWriter = new ThrowingCentralAuditWriter();
var actor = Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
serviceProvider,
new NotificationOutboxOptions
{
DispatchInterval = TimeSpan.FromHours(1),
PurgeInterval = TimeSpan.FromDays(1),
},
(ICentralAuditWriter)throwingWriter,
NullLogger<NotificationOutboxActor>.Instance)));
actor.Tell(InternalMessages.DispatchTick.Instance);
// Notifications table reflects the successful delivery even though
// every audit write threw — the central direct-write writer
// catches/logs internally and the dispatcher catches defensively too
// (alog.md §13).
await AwaitAssertAsync(async () =>
{
await using var ctx = CreateContext();
var row = await ctx.Notifications.SingleAsync(
n => n.NotificationId == notificationId.ToString("D"));
Assert.Equal(NotificationStatus.Delivered, row.Status);
Assert.NotNull(row.DeliveredAt);
}, TimeSpan.FromSeconds(15));
Assert.True(throwingWriter.Attempts >= 1,
$"Expected dispatcher to attempt audit write at least once; saw {throwingWriter.Attempts}.");
}
// ---------------------------------------------------------------------
// Test infrastructure
// ---------------------------------------------------------------------
private ScadaBridgeDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer(_fixture.ConnectionString)
.ConfigureWarnings(w => w.Ignore(
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning))
.Options;
return new ScadaBridgeDbContext(options);
}
private IServiceProvider BuildNotificationDispatcherProvider(
INotificationDeliveryAdapter adapter)
{
var services = new ServiceCollection();
services.AddDbContext<ScadaBridgeDbContext>(opts =>
opts.UseSqlServer(_fixture.ConnectionString)
.ConfigureWarnings(w => w.Ignore(
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
services.AddScoped<INotificationOutboxRepository>(sp =>
new NotificationOutboxRepository(sp.GetRequiredService<ScadaBridgeDbContext>()));
services.AddScoped<INotificationRepository>(sp =>
new NotificationRepository(sp.GetRequiredService<ScadaBridgeDbContext>()));
services.AddScoped<INotificationDeliveryAdapter>(_ => adapter);
return services.BuildServiceProvider();
}
private async Task SeedSmtpConfigAsync()
{
await using var ctx = CreateContext();
// NO-002: dispatcher clamps non-positive RetryDelay to the 1-minute fallback;
// use 1 ms so a transient outcome's NextAttemptAt is still effectively due.
ctx.SmtpConfigurations.Add(new SmtpConfiguration(
"smtp.example.com", "Basic", "noreply@example.com")
{
MaxRetries = 5,
RetryDelay = TimeSpan.FromMilliseconds(1),
});
await ctx.SaveChangesAsync();
}
private async Task SeedNotificationAsync(Guid notificationId, string siteId)
{
await using var ctx = CreateContext();
ctx.Notifications.Add(new Notification(
notificationId.ToString("D"),
NotificationType.Email,
"ops-team",
"Safety subject",
"Safety body",
siteId)
{
SourceInstanceId = "Plant.Pump42",
SourceScript = "AlarmScript",
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-1),
});
await ctx.SaveChangesAsync();
}
/// <summary>
/// Single-outcome adapter — returns the same <see cref="DeliveryOutcome"/>
/// for every call. Used by the dispatcher safety test where we only need
/// one happy-path delivery.
/// </summary>
private sealed class SingleOutcomeAdapter : INotificationDeliveryAdapter
{
private readonly DeliveryOutcome _outcome;
public SingleOutcomeAdapter(DeliveryOutcome outcome) { _outcome = outcome; }
public NotificationType Type => NotificationType.Email;
public Task<DeliveryOutcome> DeliverAsync(
Notification notification, CancellationToken cancellationToken = default)
=> Task.FromResult(_outcome);
}
/// <summary>
/// Builds an in-memory TestHost mirroring the production inbound-API
/// pipeline order. The supplied <paramref name="writer"/> stands in for
/// the production <see cref="ICentralAuditWriter"/> so the safety test can
/// install the always-throwing variant without standing up any DB.
/// </summary>
private static async Task<IHost> BuildInboundApiHostAsync(
ICentralAuditWriter writer, int endpointStatus)
{
var hostBuilder = new HostBuilder()
.ConfigureWebHost(webBuilder =>
{
webBuilder
.UseTestServer()
.ConfigureServices(services =>
{
services.AddSingleton(writer);
services.AddRouting();
services.AddAuthorization();
services.AddAuthentication("TestScheme")
.AddScheme<Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions,
AlwaysAuthenticatedHandler>("TestScheme", _ => { });
})
.Configure(app =>
{
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseAuditWriteMiddleware();
app.UseEndpoints(endpoints =>
{
endpoints.MapPost("/api/{methodName}", async ctx =>
{
ctx.Items[AuditWriteMiddleware.AuditActorItemKey] = "safety-actor";
ctx.Response.StatusCode = endpointStatus;
await ctx.Response.WriteAsync("ok");
});
});
});
});
return await hostBuilder.StartAsync();
}
private sealed class AlwaysAuthenticatedHandler
: Microsoft.AspNetCore.Authentication.AuthenticationHandler<
Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions>
{
public AlwaysAuthenticatedHandler(
IOptionsMonitor<Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions> options,
Microsoft.Extensions.Logging.ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder) { }
protected override Task<Microsoft.AspNetCore.Authentication.AuthenticateResult>
HandleAuthenticateAsync()
{
var identity = new ClaimsIdentity(
new[] { new Claim(ClaimTypes.Name, "framework-user") }, "TestScheme");
var principal = new ClaimsPrincipal(identity);
var ticket = new Microsoft.AspNetCore.Authentication.AuthenticationTicket(
principal, "TestScheme");
return Task.FromResult(
Microsoft.AspNetCore.Authentication.AuthenticateResult.Success(ticket));
}
}
}
@@ -0,0 +1,271 @@
using Akka.TestKit.Xunit2;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
/// <summary>
/// Bundle G G2 end-to-end suite for cached <c>ExternalSystem.CachedCall</c>
/// lifecycle telemetry (Audit Log #23 / M3). Wires the full M3 pipeline:
/// site-local SQLite audit writer + operation tracking store + the production
/// <see cref="CachedCallTelemetryForwarder"/> + the test-side
/// <see cref="CombinedTelemetryDispatcher"/> that ALSO pushes each combined
/// packet through the stub gRPC client into the central
/// <c>AuditLogIngestActor</c>'s dual-write transaction against a per-test
/// MSSQL database. Asserts the audit rows + the SiteCalls row + the
/// site-local tracking row converge to the expected shape for each lifecycle.
/// </summary>
/// <remarks>
/// <para>
/// The bridge is driven directly via <see cref="CombinedTelemetryHarness.EmitAttemptAsync"/>
/// — these tests do NOT spin up the actual S&amp;F retry loop; that would
/// require a full SiteRuntime host and is out of scope for M3 (the S&amp;F
/// observer hooks are exercised in <c>ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests</c> at
/// unit level). The submit row is emitted via
/// <see cref="CombinedTelemetryHarness.EmitSubmitAsync"/> because the
/// production submit emission happens at the script-call site, not inside the
/// S&amp;F loop.
/// </para>
/// <para>
/// Each test uses a unique <c>SourceSite</c> id (Guid suffix) so concurrent
/// tests sharing the per-fixture MSSQL database don't interfere with each
/// other.
/// </para>
/// </remarks>
public class CachedCallCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public CachedCallCombinedTelemetryTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
private static string NewSiteId() =>
"test-g2-cached-" + Guid.NewGuid().ToString("N").Substring(0, 8);
private static CachedCallTelemetry SubmitPacket(
TrackedOperationId id, string siteId, DateTime nowUtc, string target = "ERP.GetOrder") =>
new(
Audit: new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = nowUtc,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.CachedSubmit,
CorrelationId = id.Value,
SourceSiteId = siteId,
SourceInstanceId = "Plant.Pump42",
SourceScript = "ScriptActor:doStuff",
Target = target,
Status = AuditStatus.Submitted,
ForwardState = AuditForwardState.Pending,
},
Operational: new SiteCallOperational(
TrackedOperationId: id,
Channel: "ApiOutbound",
Target: target,
SourceSite: siteId,
SourceNode: null,
Status: "Submitted",
RetryCount: 0,
LastError: null,
HttpStatus: null,
CreatedAtUtc: nowUtc,
UpdatedAtUtc: nowUtc,
TerminalAtUtc: null));
private static CachedCallAttemptContext AttemptContext(
TrackedOperationId id,
string siteId,
CachedCallAttemptOutcome outcome,
int retryCount,
string? lastError,
int? httpStatus,
DateTime createdUtc,
DateTime occurredUtc,
string target = "ERP.GetOrder",
string channel = "ApiOutbound") =>
new(
TrackedOperationId: id,
Channel: channel,
Target: target,
SourceSite: siteId,
Outcome: outcome,
RetryCount: retryCount,
LastError: lastError,
HttpStatus: httpStatus,
CreatedAtUtc: createdUtc,
OccurredAtUtc: occurredUtc,
DurationMs: 42,
SourceInstanceId: "Plant.Pump42");
[SkippableFact]
public async Task CachedCall_FailFailSuccess_Emits_5_AuditRows_AND_1_SiteCall_Delivered()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var trackedId = TrackedOperationId.New();
var t0 = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
await using var harness = new CombinedTelemetryHarness(_fixture, this);
// Submit
await harness.EmitSubmitAsync(SubmitPacket(trackedId, siteId, t0));
// Attempt 1: transient HTTP 500
await harness.EmitAttemptAsync(AttemptContext(
trackedId, siteId,
CachedCallAttemptOutcome.TransientFailure,
retryCount: 1, lastError: "HTTP 500", httpStatus: 500,
createdUtc: t0, occurredUtc: t0.AddSeconds(5)));
// Attempt 2: transient HTTP 500
await harness.EmitAttemptAsync(AttemptContext(
trackedId, siteId,
CachedCallAttemptOutcome.TransientFailure,
retryCount: 2, lastError: "HTTP 500", httpStatus: 500,
createdUtc: t0, occurredUtc: t0.AddSeconds(15)));
// Attempt 3: delivered (terminal — emits Attempted + CachedResolve)
await harness.EmitAttemptAsync(AttemptContext(
trackedId, siteId,
CachedCallAttemptOutcome.Delivered,
retryCount: 3, lastError: null, httpStatus: 200,
createdUtc: t0, occurredUtc: t0.AddSeconds(25)));
// Central side: each forward through the dispatcher round-trips
// through the stub client + ingest actor, so by the time the awaits
// complete the rows are visible in MSSQL.
await using var read = harness.CreateReadContext();
// 1 Submit + 2 transient Attempted + 1 terminal Attempted + 1
// CachedResolve = 5 audit rows. The plan allows 4-5; this is the
// happy path emitting exactly 5.
var auditRows = await read.Set<AuditEvent>()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
Assert.InRange(auditRows.Count, 4, 5);
// All audit rows must share the same CorrelationId (= TrackedOperationId).
Assert.All(auditRows, r => Assert.Equal(trackedId.Value, r.CorrelationId));
// Exactly one CachedSubmit row.
Assert.Single(auditRows, r => r.Kind == AuditKind.CachedSubmit);
// Exactly one terminal CachedResolve row, status Delivered.
var resolve = Assert.Single(auditRows, r => r.Kind == AuditKind.CachedResolve);
Assert.Equal(AuditStatus.Delivered, resolve.Status);
// SiteCalls row: Delivered, RetryCount=3, TerminalAtUtc set.
var siteCall = await read.Set<SiteCall>()
.SingleAsync(s => s.TrackedOperationId == trackedId);
Assert.Equal("Delivered", siteCall.Status);
Assert.Equal(3, siteCall.RetryCount);
Assert.NotNull(siteCall.TerminalAtUtc);
// Site-local Tracking.Status mirrors the same outcome.
var snapshot = await harness.TrackingStore.GetStatusAsync(trackedId);
Assert.NotNull(snapshot);
Assert.Equal("Delivered", snapshot!.Status);
Assert.NotNull(snapshot.TerminalAtUtc);
}
[SkippableFact]
public async Task CachedCall_AllAttemptsFailedAndParked_Emits_Terminal_Parked()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var trackedId = TrackedOperationId.New();
var t0 = new DateTime(2026, 5, 20, 11, 0, 0, DateTimeKind.Utc);
await using var harness = new CombinedTelemetryHarness(_fixture, this);
await harness.EmitSubmitAsync(SubmitPacket(trackedId, siteId, t0));
// Three transient failures...
for (int i = 1; i <= 3; i++)
{
await harness.EmitAttemptAsync(AttemptContext(
trackedId, siteId,
CachedCallAttemptOutcome.TransientFailure,
retryCount: i, lastError: "HTTP 500", httpStatus: 500,
createdUtc: t0, occurredUtc: t0.AddSeconds(i * 5)));
}
// ...then S&F gives up — ParkedMaxRetries.
await harness.EmitAttemptAsync(AttemptContext(
trackedId, siteId,
CachedCallAttemptOutcome.ParkedMaxRetries,
retryCount: 4, lastError: "HTTP 500", httpStatus: 500,
createdUtc: t0, occurredUtc: t0.AddSeconds(30)));
await using var read = harness.CreateReadContext();
var siteCall = await read.Set<SiteCall>()
.SingleAsync(s => s.TrackedOperationId == trackedId);
Assert.Equal("Parked", siteCall.Status);
Assert.NotNull(siteCall.TerminalAtUtc);
// Terminal audit row should also be Parked.
var resolve = await read.Set<AuditEvent>()
.Where(e => e.SourceSiteId == siteId && e.Kind == AuditKind.CachedResolve)
.SingleAsync();
Assert.Equal(AuditStatus.Parked, resolve.Status);
// Site-local tracking matches.
var snapshot = await harness.TrackingStore.GetStatusAsync(trackedId);
Assert.NotNull(snapshot);
Assert.Equal("Parked", snapshot!.Status);
Assert.NotNull(snapshot.TerminalAtUtc);
}
[SkippableFact]
public async Task CachedCall_ImmediateSuccess_NoSF_Emits_Attempted_And_Resolve_Delivered()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var trackedId = TrackedOperationId.New();
var t0 = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc);
await using var harness = new CombinedTelemetryHarness(_fixture, this);
// Submit + immediate delivered attempt (RetryCount = 0).
await harness.EmitSubmitAsync(SubmitPacket(trackedId, siteId, t0));
await harness.EmitAttemptAsync(AttemptContext(
trackedId, siteId,
CachedCallAttemptOutcome.Delivered,
retryCount: 0, lastError: null, httpStatus: 200,
createdUtc: t0, occurredUtc: t0.AddMilliseconds(50)));
await using var read = harness.CreateReadContext();
var siteCall = await read.Set<SiteCall>()
.SingleAsync(s => s.TrackedOperationId == trackedId);
Assert.Equal("Delivered", siteCall.Status);
Assert.Equal(0, siteCall.RetryCount);
Assert.NotNull(siteCall.TerminalAtUtc);
// 1 Submit + 1 Attempted + 1 CachedResolve = 3 audit rows.
var auditRows = await read.Set<AuditEvent>()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
Assert.Equal(3, auditRows.Count);
Assert.Single(auditRows, r => r.Kind == AuditKind.CachedSubmit);
Assert.Single(auditRows, r => r.Kind == AuditKind.ApiCallCached);
var resolve = Assert.Single(auditRows, r => r.Kind == AuditKind.CachedResolve);
Assert.Equal(AuditStatus.Delivered, resolve.Status);
var snapshot = await harness.TrackingStore.GetStatusAsync(trackedId);
Assert.NotNull(snapshot);
Assert.Equal("Delivered", snapshot!.Status);
}
}
@@ -0,0 +1,197 @@
using Akka.TestKit.Xunit2;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
/// <summary>
/// Bundle G G3 mirror of <see cref="CachedCallCombinedTelemetryTests"/> for
/// <c>Database.CachedWrite</c>. Same pipeline composition, same dual-write
/// transaction, but the lifecycle bridge maps channel <c>"DbOutbound"</c> to
/// <see cref="AuditKind.DbWriteCached"/> on the per-attempt audit row (vs.
/// <see cref="AuditKind.ApiCallCached"/> for API calls). The
/// <see cref="AuditChannel"/> on the audit row, the <c>SiteCalls.Channel</c>
/// column, and the per-attempt <see cref="AuditKind"/> all need to come
/// through as the DB variants for this path to be considered exercised.
/// </summary>
/// <remarks>
/// As with G2, the bridge is driven directly via the harness — we do not
/// stand up a real <c>Database.CachedWrite</c> caller. The site-side
/// unit-level emission for the DB path is exercised in
/// <c>ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests</c>; this suite verifies the end-to-end
/// combined-telemetry path produces the right central rows.
/// </remarks>
public class CachedWriteCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public CachedWriteCombinedTelemetryTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
private static string NewSiteId() =>
"test-g3-cachedwrite-" + Guid.NewGuid().ToString("N").Substring(0, 8);
private static CachedCallTelemetry DbSubmitPacket(
TrackedOperationId id, string siteId, DateTime nowUtc, string target = "OperationsDb.UpdateOrder") =>
new(
Audit: new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = nowUtc,
Channel = AuditChannel.DbOutbound,
Kind = AuditKind.CachedSubmit,
CorrelationId = id.Value,
SourceSiteId = siteId,
SourceInstanceId = "Plant.Pump42",
SourceScript = "ScriptActor:doStuff",
Target = target,
Status = AuditStatus.Submitted,
ForwardState = AuditForwardState.Pending,
},
Operational: new SiteCallOperational(
TrackedOperationId: id,
Channel: "DbOutbound",
Target: target,
SourceSite: siteId,
SourceNode: null,
Status: "Submitted",
RetryCount: 0,
LastError: null,
HttpStatus: null,
CreatedAtUtc: nowUtc,
UpdatedAtUtc: nowUtc,
TerminalAtUtc: null));
private static CachedCallAttemptContext DbAttemptContext(
TrackedOperationId id,
string siteId,
CachedCallAttemptOutcome outcome,
int retryCount,
string? lastError,
DateTime createdUtc,
DateTime occurredUtc,
string target = "OperationsDb.UpdateOrder") =>
new(
TrackedOperationId: id,
Channel: "DbOutbound",
Target: target,
SourceSite: siteId,
Outcome: outcome,
RetryCount: retryCount,
LastError: lastError,
HttpStatus: null,
CreatedAtUtc: createdUtc,
OccurredAtUtc: occurredUtc,
DurationMs: 12,
SourceInstanceId: "Plant.Pump42");
[SkippableFact]
public async Task CachedWrite_Success_Emits_Delivered_Lifecycle()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var trackedId = TrackedOperationId.New();
var t0 = new DateTime(2026, 5, 20, 13, 0, 0, DateTimeKind.Utc);
await using var harness = new CombinedTelemetryHarness(_fixture, this);
// Submit + immediate delivered attempt.
await harness.EmitSubmitAsync(DbSubmitPacket(trackedId, siteId, t0));
await harness.EmitAttemptAsync(DbAttemptContext(
trackedId, siteId,
CachedCallAttemptOutcome.Delivered,
retryCount: 0, lastError: null,
createdUtc: t0, occurredUtc: t0.AddMilliseconds(50)));
await using var read = harness.CreateReadContext();
// Central SiteCalls row — DbOutbound channel, Delivered.
var siteCall = await read.Set<SiteCall>()
.SingleAsync(s => s.TrackedOperationId == trackedId);
Assert.Equal("DbOutbound", siteCall.Channel);
Assert.Equal("Delivered", siteCall.Status);
Assert.Equal(0, siteCall.RetryCount);
Assert.NotNull(siteCall.TerminalAtUtc);
var auditRows = await read.Set<AuditEvent>()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
Assert.Equal(3, auditRows.Count);
// Submit row: CachedSubmit + DbOutbound channel.
var submit = Assert.Single(auditRows, r => r.Kind == AuditKind.CachedSubmit);
Assert.Equal(AuditChannel.DbOutbound, submit.Channel);
// Per-attempt row: DbWriteCached (NOT ApiCallCached).
var attempt = Assert.Single(auditRows, r => r.Kind == AuditKind.DbWriteCached);
Assert.Equal(AuditStatus.Attempted, attempt.Status);
Assert.Equal(AuditChannel.DbOutbound, attempt.Channel);
// Terminal: CachedResolve Delivered.
var resolve = Assert.Single(auditRows, r => r.Kind == AuditKind.CachedResolve);
Assert.Equal(AuditStatus.Delivered, resolve.Status);
Assert.Equal(AuditChannel.DbOutbound, resolve.Channel);
// Site-local tracking row mirrors the same outcome.
var snapshot = await harness.TrackingStore.GetStatusAsync(trackedId);
Assert.NotNull(snapshot);
Assert.Equal("Delivered", snapshot!.Status);
}
[SkippableFact]
public async Task CachedWrite_Parked_Emits_Terminal_Parked()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var trackedId = TrackedOperationId.New();
var t0 = new DateTime(2026, 5, 20, 14, 0, 0, DateTimeKind.Utc);
await using var harness = new CombinedTelemetryHarness(_fixture, this);
await harness.EmitSubmitAsync(DbSubmitPacket(trackedId, siteId, t0));
// Two transient SQL-error attempts...
for (int i = 1; i <= 2; i++)
{
await harness.EmitAttemptAsync(DbAttemptContext(
trackedId, siteId,
CachedCallAttemptOutcome.TransientFailure,
retryCount: i, lastError: "Deadlock victim",
createdUtc: t0, occurredUtc: t0.AddSeconds(i * 5)));
}
// ...then permanent failure → Parked terminal.
await harness.EmitAttemptAsync(DbAttemptContext(
trackedId, siteId,
CachedCallAttemptOutcome.PermanentFailure,
retryCount: 3, lastError: "ConstraintViolation: FK_Orders_Customer",
createdUtc: t0, occurredUtc: t0.AddSeconds(20)));
await using var read = harness.CreateReadContext();
var siteCall = await read.Set<SiteCall>()
.SingleAsync(s => s.TrackedOperationId == trackedId);
Assert.Equal("DbOutbound", siteCall.Channel);
Assert.Equal("Parked", siteCall.Status);
Assert.NotNull(siteCall.TerminalAtUtc);
var resolve = await read.Set<AuditEvent>()
.Where(e => e.SourceSiteId == siteId && e.Kind == AuditKind.CachedResolve)
.SingleAsync();
Assert.Equal(AuditStatus.Parked, resolve.Status);
Assert.Equal(AuditChannel.DbOutbound, resolve.Channel);
// Tracking store mirrors Parked.
var snapshot = await harness.TrackingStore.GetStatusAsync(trackedId);
Assert.NotNull(snapshot);
Assert.Equal("Parked", snapshot!.Status);
Assert.NotNull(snapshot.TerminalAtUtc);
}
}
@@ -0,0 +1,201 @@
using Akka.TestKit.Xunit2;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
using Timestamp = Google.Protobuf.WellKnownTypes.Timestamp;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
/// <summary>
/// Bundle G G4 idempotency suite. Telemetry packets MUST round-trip safely
/// under retried delivery (at-least-once site→central) AND under out-of-order
/// arrival (a stale Submit packet arriving after the central row has already
/// advanced to Attempted must not regress the SiteCalls status, but must
/// still insert its own audit row because audit rows are append-only and the
/// lifecycle history is the source of truth for forensics).
/// </summary>
/// <remarks>
/// Pushes <see cref="CachedTelemetryBatch"/> packets directly through the
/// stub client (bypassing the local SQLite writer + tracking store) — the
/// scenario being modeled is a wire-level retry, not a fresh site call, so
/// the local stores' insert/no-op behaviour is already covered by the G2/G3
/// happy-path tests. This suite focuses on the central ingest actor's
/// dual-write transaction's idempotency contract.
/// </remarks>
public class CombinedTelemetryIdempotencyTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public CombinedTelemetryIdempotencyTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
private static string NewSiteId() =>
"test-g4-idem-" + Guid.NewGuid().ToString("N").Substring(0, 8);
private static CachedTelemetryPacket BuildPacket(
Guid eventId,
TrackedOperationId trackedId,
string siteId,
AuditKind kind,
AuditStatus auditStatus,
string operationalStatus,
int retryCount,
DateTime nowUtc,
DateTime? terminalUtc = null,
string? lastError = null,
int? httpStatus = null)
{
var dto = new CachedTelemetryPacket
{
AuditEvent = AuditEventDtoMapper.ToDto(new AuditEvent
{
EventId = eventId,
OccurredAtUtc = nowUtc,
Channel = AuditChannel.ApiOutbound,
Kind = kind,
CorrelationId = trackedId.Value,
SourceSiteId = siteId,
Target = "ERP.GetOrder",
Status = auditStatus,
HttpStatus = httpStatus,
ErrorMessage = lastError,
ForwardState = AuditForwardState.Pending,
}),
Operational = new SiteCallOperationalDto
{
TrackedOperationId = trackedId.Value.ToString("D"),
Channel = "ApiOutbound",
Target = "ERP.GetOrder",
SourceSite = siteId,
Status = operationalStatus,
RetryCount = retryCount,
LastError = lastError ?? string.Empty,
CreatedAtUtc = Timestamp.FromDateTime(DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc)),
UpdatedAtUtc = Timestamp.FromDateTime(DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc)),
},
};
if (httpStatus.HasValue)
{
dto.Operational.HttpStatus = httpStatus.Value;
}
if (terminalUtc.HasValue)
{
dto.Operational.TerminalAtUtc =
Timestamp.FromDateTime(DateTime.SpecifyKind(terminalUtc.Value, DateTimeKind.Utc));
}
return dto;
}
private static CachedTelemetryBatch BatchOf(params CachedTelemetryPacket[] packets)
{
var batch = new CachedTelemetryBatch();
batch.Packets.AddRange(packets);
return batch;
}
[SkippableFact]
public async Task DuplicatePacket_AuditLogStaysAtOneRow_SiteCallsUpserted_Monotonically()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var trackedId = TrackedOperationId.New();
var eventId = Guid.NewGuid();
var t0 = new DateTime(2026, 5, 20, 15, 0, 0, DateTimeKind.Utc);
await using var harness = new CombinedTelemetryHarness(_fixture, this);
var packet = BuildPacket(
eventId, trackedId, siteId,
AuditKind.CachedSubmit, AuditStatus.Submitted, "Submitted",
retryCount: 0, nowUtc: t0);
// First delivery
var ack1 = await harness.StubClient.IngestCachedTelemetryAsync(BatchOf(packet), CancellationToken.None);
Assert.Single(ack1.AcceptedEventIds);
// Second delivery — the exact same packet (simulates a retried gRPC).
var ack2 = await harness.StubClient.IngestCachedTelemetryAsync(BatchOf(packet), CancellationToken.None);
// Central acks both deliveries because storage state is consistent —
// the site is free to treat its local row as Forwarded either way.
Assert.Single(ack2.AcceptedEventIds);
Assert.Equal(eventId.ToString(), ack2.AcceptedEventIds[0]);
await using var read = harness.CreateReadContext();
// AuditLog: exactly ONE row for the EventId (insert-if-not-exists).
var auditCount = await read.Set<AuditEvent>()
.CountAsync(e => e.EventId == eventId);
Assert.Equal(1, auditCount);
// SiteCalls: exactly ONE row for the TrackedOperationId.
var siteCalls = await read.Set<SiteCall>()
.Where(s => s.TrackedOperationId == trackedId)
.ToListAsync();
Assert.Single(siteCalls);
Assert.Equal("Submitted", siteCalls[0].Status);
Assert.Equal(0, siteCalls[0].RetryCount);
}
[SkippableFact]
public async Task OutOfOrderPackets_OlderStatus_ArrivesAfterNewer_IsNoOp()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var trackedId = TrackedOperationId.New();
var t0 = new DateTime(2026, 5, 20, 16, 0, 0, DateTimeKind.Utc);
await using var harness = new CombinedTelemetryHarness(_fixture, this);
// First: the Attempted (RetryCount=2) row arrives at central — perhaps
// the Submit packet got delayed in flight. SiteCalls advances straight
// to Attempted with retry count 2.
var attemptedEventId = Guid.NewGuid();
var attemptedPacket = BuildPacket(
attemptedEventId, trackedId, siteId,
AuditKind.ApiCallCached, AuditStatus.Attempted, "Attempted",
retryCount: 2, nowUtc: t0.AddSeconds(10),
lastError: "HTTP 500", httpStatus: 500);
var ack1 = await harness.StubClient.IngestCachedTelemetryAsync(BatchOf(attemptedPacket), CancellationToken.None);
Assert.Single(ack1.AcceptedEventIds);
// Now the stale Submit packet shows up. The audit row should still be
// inserted (audit is append-only — preserve the lifecycle history),
// but SiteCalls must NOT regress to Submitted/RetryCount=0.
var submitEventId = Guid.NewGuid();
var submitPacket = BuildPacket(
submitEventId, trackedId, siteId,
AuditKind.CachedSubmit, AuditStatus.Submitted, "Submitted",
retryCount: 0, nowUtc: t0);
var ack2 = await harness.StubClient.IngestCachedTelemetryAsync(BatchOf(submitPacket), CancellationToken.None);
Assert.Single(ack2.AcceptedEventIds);
await using var read = harness.CreateReadContext();
// AuditLog: TWO rows now exist for this lifecycle — the Submit and
// the Attempted. Their order is by OccurredAtUtc; the test doesn't
// assert ordering, only count + correlation.
var auditRows = await read.Set<AuditEvent>()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
Assert.Equal(2, auditRows.Count);
Assert.All(auditRows, r => Assert.Equal(trackedId.Value, r.CorrelationId));
// SiteCalls: stuck at Attempted (monotonic — Submitted is rank 0,
// Attempted is rank 2, the upsert for the older row is a no-op).
var siteCall = await read.Set<SiteCall>()
.SingleAsync(s => s.TrackedOperationId == trackedId);
Assert.Equal("Attempted", siteCall.Status);
Assert.Equal(2, siteCall.RetryCount);
Assert.Equal("HTTP 500", siteCall.LastError);
Assert.Equal(500, siteCall.HttpStatus);
}
}
@@ -0,0 +1,300 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
/// <summary>
/// Audit Log #23 — M4 Bundle E (Task E1) end-to-end suite verifying every
/// synchronous <c>Database.Connection(name).Execute*</c> /
/// <c>ExecuteReader</c> call made via the Bundle A
/// <see cref="ScriptRuntimeContext.DatabaseHelper"/> emits exactly one
/// <see cref="AuditChannel.DbOutbound"/>/<see cref="AuditKind.DbWrite"/> row
/// that materialises in the central MSSQL <c>AuditLog</c> via the production
/// site-SQLite + telemetry-actor + central ingest-actor pipeline.
/// </summary>
/// <remarks>
/// <para>
/// Composes the same pipeline as the M2 <see cref="SyncCallEmissionEndToEndTests"/>:
/// in-memory <see cref="SqliteAuditWriter"/> + <see cref="RingBufferFallback"/> +
/// <see cref="FallbackAuditWriter"/> on the site, drained by a real
/// <see cref="SiteAuditTelemetryActor"/> through a
/// <see cref="DirectActorSiteStreamAuditClient"/> stub that short-circuits the
/// gRPC wire and Asks the central <see cref="AuditLogIngestActor"/> backed by
/// the real <see cref="AuditLogRepository"/> on the per-class
/// <see cref="MsSqlMigrationFixture"/> MSSQL database.
/// </para>
/// <para>
/// Drives the AuditingDbConnection wrapper directly via
/// <see cref="ScriptRuntimeContext.DatabaseHelper"/>'s internal ctor (the
/// AuditLog tests project has <c>InternalsVisibleTo</c> on SiteRuntime). No
/// script runtime, no Akka Instance Actor — the test wires the helper, opens
/// an in-memory SQLite connection through a stub <see cref="IDatabaseGateway"/>,
/// runs one SQL statement, and waits for the central row to land. Each test
/// uses a unique <c>SourceSiteId</c> (Guid suffix) so concurrent tests
/// sharing the MSSQL fixture don't interfere with each other.
/// </para>
/// </remarks>
public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public DatabaseSyncEmissionEndToEndTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
private const string ConnectionName = "machineData";
private const string InstanceName = "Plant.Pump42";
private const string SourceScript = "ScriptActor:doDbWork";
private static string NewSiteId() =>
"test-e1-db-" + Guid.NewGuid().ToString("N").Substring(0, 8);
private ScadaBridgeDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer(_fixture.ConnectionString)
.Options;
return new ScadaBridgeDbContext(options);
}
/// <summary>
/// Per-test in-memory SQLite database with a tiny 2-row schema we can both
/// write to and select from. Mirrors the pattern from
/// <c>DatabaseSyncEmissionTests</c> — the keep-alive root keeps the
/// in-memory database file pinned for the duration of the test, while the
/// returned <c>live</c> connection is what the stub gateway hands back to
/// the auditing wrapper.
/// </summary>
private static SqliteConnection NewInMemoryDb(out SqliteConnection keepAlive)
{
var dbName = $"db-{Guid.NewGuid():N}";
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
keepAlive = new SqliteConnection(connStr);
keepAlive.Open();
using (var seed = keepAlive.CreateCommand())
{
seed.CommandText =
"CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT NOT NULL);" +
"INSERT INTO t (id, name) VALUES (1, 'alpha');" +
"INSERT INTO t (id, name) VALUES (2, 'beta');";
seed.ExecuteNonQuery();
}
var live = new SqliteConnection(connStr);
live.Open();
return live;
}
private static SqliteAuditWriter CreateInMemorySqliteWriter() =>
new(
Options.Create(new SqliteAuditWriterOptions
{
DatabasePath = "ignored",
BatchSize = 64,
ChannelCapacity = 1024,
}),
NullLogger<SqliteAuditWriter>.Instance,
new FakeNodeIdentityProvider(),
connectionStringOverride:
$"Data Source=file:auditlog-e1-{Guid.NewGuid():N}?mode=memory&cache=shared");
private static IOptions<SiteAuditTelemetryOptions> FastTelemetryOptions() =>
Options.Create(new SiteAuditTelemetryOptions
{
BatchSize = 256,
// 1s on both intervals so the initial scheduled tick fires quickly
// — drains the SQLite Pending row and pushes it through the stub
// gRPC client into the central ingest actor.
BusyIntervalSeconds = 1,
IdleIntervalSeconds = 1,
});
private IActorRef CreateIngestActor(IAuditLogRepository repo) =>
Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
repo,
NullLogger<AuditLogIngestActor>.Instance)));
private IActorRef CreateTelemetryActor(
ISiteAuditQueue queue,
ISiteStreamAuditClient client) =>
Sys.ActorOf(Props.Create(() => new SiteAuditTelemetryActor(
queue,
client,
FastTelemetryOptions(),
NullLogger<SiteAuditTelemetryActor>.Instance)));
/// <summary>
/// Wires the production
/// <see cref="ScriptRuntimeContext.DatabaseHelper"/> (internal ctor) onto
/// the supplied <see cref="IDatabaseGateway"/> + <see cref="IAuditWriter"/>
/// with the test's site id and source script. The returned helper's
/// <c>Connection(...)</c> hands back a real <c>AuditingDbConnection</c>.
/// </summary>
private static ScriptRuntimeContext.DatabaseHelper CreateHelper(
IDatabaseGateway gateway,
IAuditWriter writer,
string siteId) =>
new(
gateway,
InstanceName,
NullLogger.Instance,
Guid.NewGuid(),
auditWriter: writer,
siteId: siteId,
sourceScript: SourceScript,
cachedForwarder: null);
[SkippableFact]
public async Task DbWrite_Insert_Emits_OneCentralRow_WithExtraOpWrite_AndRowsAffected()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
// Central — repository + ingest actor backed by the MSSQL fixture.
await using var ingestContext = CreateContext();
var ingestRepo = new AuditLogRepository(ingestContext);
var ingestActor = CreateIngestActor(ingestRepo);
// Site — SQLite audit writer + ring + fallback + telemetry actor that
// drains into the stub gRPC client which forwards to the ingest actor.
await using var sqliteWriter = CreateInMemorySqliteWriter();
var ring = new RingBufferFallback();
var fallback = new FallbackAuditWriter(
sqliteWriter,
ring,
new NoOpAuditWriteFailureCounter(),
NullLogger<FallbackAuditWriter>.Instance);
var stubClient = new DirectActorSiteStreamAuditClient(ingestActor);
CreateTelemetryActor(sqliteWriter, stubClient);
// SQLite-backed inner connection — the stub gateway hands it to the
// auditing wrapper as the DbConnection the script would have got.
using var keepAlive = new SqliteConnection("Data Source=k1;Mode=Memory;Cache=Shared");
var inner = NewInMemoryDb(out _);
var gateway = Substitute.For<IDatabaseGateway>();
gateway.GetConnectionAsync(ConnectionName, Arg.Any<CancellationToken>())
.Returns(inner);
// Act — one INSERT through the auditing wrapper. The wrapper emits a
// single DbOutbound/DbWrite event to the fallback writer; the
// telemetry actor's next tick drains it to central.
var helper = CreateHelper(gateway, fallback, siteId);
await using (var conn = await helper.Connection(ConnectionName))
await using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "INSERT INTO t (id, name) VALUES (3, 'gamma')";
var rows = await cmd.ExecuteNonQueryAsync();
Assert.Equal(1, rows);
}
// Assert — one central row, Kind=DbWrite, Status=Delivered,
// Extra.op="write", Extra.rowsAffected=1. 15s upper bound covers the
// initial 1s tick + SQLite drain + actor round-trip + EF/MSSQL latency.
await AwaitAssertAsync(async () =>
{
await using var readContext = CreateContext();
var readRepo = new AuditLogRepository(readContext);
var rows = await readRepo.QueryAsync(
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10));
var evt = Assert.Single(rows);
Assert.Equal(AuditChannel.DbOutbound, evt.Channel);
Assert.Equal(AuditKind.DbWrite, evt.Kind);
Assert.Equal(AuditStatus.Delivered, evt.Status);
Assert.Equal(siteId, evt.SourceSiteId);
Assert.Equal(InstanceName, evt.SourceInstanceId);
Assert.Equal(SourceScript, evt.SourceScript);
Assert.NotNull(evt.Extra);
Assert.Contains("\"op\":\"write\"", evt.Extra);
Assert.Contains("\"rowsAffected\":1", evt.Extra);
// Central stamps IngestedAtUtc; the site never sets it.
Assert.NotNull(evt.IngestedAtUtc);
Assert.StartsWith(ConnectionName, evt.Target);
}, TimeSpan.FromSeconds(15));
}
[SkippableFact]
public async Task DbWrite_Select_Emits_OneCentralRow_WithExtraOpRead_AndRowsReturned()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var ingestContext = CreateContext();
var ingestRepo = new AuditLogRepository(ingestContext);
var ingestActor = CreateIngestActor(ingestRepo);
await using var sqliteWriter = CreateInMemorySqliteWriter();
var ring = new RingBufferFallback();
var fallback = new FallbackAuditWriter(
sqliteWriter,
ring,
new NoOpAuditWriteFailureCounter(),
NullLogger<FallbackAuditWriter>.Instance);
var stubClient = new DirectActorSiteStreamAuditClient(ingestActor);
CreateTelemetryActor(sqliteWriter, stubClient);
using var keepAlive = new SqliteConnection("Data Source=k2;Mode=Memory;Cache=Shared");
var inner = NewInMemoryDb(out _);
var gateway = Substitute.For<IDatabaseGateway>();
gateway.GetConnectionAsync(ConnectionName, Arg.Any<CancellationToken>())
.Returns(inner);
var helper = CreateHelper(gateway, fallback, siteId);
await using (var conn = await helper.Connection(ConnectionName))
await using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "SELECT id, name FROM t ORDER BY id";
await using var reader = await cmd.ExecuteReaderAsync();
var seen = 0;
while (await reader.ReadAsync())
{
seen++;
}
// Explicit close so the AuditingDbDataReader callback fires before
// the helper is disposed (Bundle A defers the audit emission to
// reader-close so rowsReturned is observable).
await reader.CloseAsync();
Assert.Equal(2, seen);
}
await AwaitAssertAsync(async () =>
{
await using var readContext = CreateContext();
var readRepo = new AuditLogRepository(readContext);
var rows = await readRepo.QueryAsync(
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10));
var evt = Assert.Single(rows);
Assert.Equal(AuditChannel.DbOutbound, evt.Channel);
Assert.Equal(AuditKind.DbWrite, evt.Kind);
Assert.Equal(AuditStatus.Delivered, evt.Status);
Assert.NotNull(evt.Extra);
Assert.Contains("\"op\":\"read\"", evt.Extra);
Assert.Contains("\"rowsReturned\":2", evt.Extra);
Assert.NotNull(evt.IngestedAtUtc);
}, TimeSpan.FromSeconds(15));
}
}
@@ -0,0 +1,276 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
/// <summary>
/// Audit Log #23 — ExecutionId end-to-end correlation suite verifying the
/// universal per-run correlation promise: <b>every audit row produced by one
/// script execution carries the same non-null <see cref="AuditEvent.ExecutionId"/></b>.
/// </summary>
/// <remarks>
/// <para>
/// This is the integration-level counterpart to the unit-level
/// <c>ExecutionCorrelationContextTests</c> in <c>ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests</c>:
/// where that test asserts the shared id on the in-memory captured rows, this
/// suite drives the rows all the way through the production pipeline — the real
/// <see cref="SqliteAuditWriter"/> site hot-path, the real
/// <see cref="SiteAuditTelemetryActor"/> drain loop, the real
/// <see cref="AuditLogIngestActor"/>, and the real <see cref="AuditLogRepository"/>
/// over the per-class <see cref="MsSqlMigrationFixture"/> MSSQL database — then
/// reads the rows back from the central store and asserts the shared id.
/// </para>
/// <para>
/// Composes the same pipeline as the M2 <see cref="SyncCallEmissionEndToEndTests"/>
/// and the M4 <see cref="DatabaseSyncEmissionEndToEndTests"/>: an in-memory
/// <see cref="SqliteAuditWriter"/> + <see cref="RingBufferFallback"/> +
/// <see cref="FallbackAuditWriter"/> on the site, drained by a real
/// <see cref="SiteAuditTelemetryActor"/> through the shared
/// <see cref="DirectActorSiteStreamAuditClient"/> stub that short-circuits the
/// gRPC wire and Asks the central ingest actor. The production
/// <see cref="ScriptRuntimeContext"/> is driven directly: one context performs
/// two distinct trust-boundary actions — a sync <c>ExternalSystem.Call</c> and a
/// sync <c>Database</c> write — so the two emitted audit rows originate from one
/// execution. Each test uses a unique <c>ExecutionId</c> + <c>SourceSiteId</c>
/// (Guid suffixes) so concurrent tests sharing the MSSQL fixture don't interfere.
/// </para>
/// </remarks>
public class ExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public ExecutionIdCorrelationTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
private const string ConnectionName = "machineData";
private const string InstanceName = "Plant.Pump42";
private const string SourceScript = "ScriptActor:OnTick";
private static string NewSiteId() =>
"test-execid-" + Guid.NewGuid().ToString("N").Substring(0, 8);
private ScadaBridgeDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer(_fixture.ConnectionString)
.Options;
return new ScadaBridgeDbContext(options);
}
/// <summary>
/// Per-test in-memory SQLite database with a tiny single-table schema the
/// sync DB write targets. The keep-alive root pins the in-memory file for
/// the duration of the test; the returned <c>live</c> connection is what the
/// stub gateway hands back to the auditing wrapper. Mirrors
/// <c>DatabaseSyncEmissionEndToEndTests.NewInMemoryDb</c>.
/// </summary>
private static SqliteConnection NewInMemoryDb(out SqliteConnection keepAlive)
{
var dbName = $"db-{Guid.NewGuid():N}";
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
keepAlive = new SqliteConnection(connStr);
keepAlive.Open();
using (var seed = keepAlive.CreateCommand())
{
seed.CommandText =
"CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT NOT NULL);";
seed.ExecuteNonQuery();
}
var live = new SqliteConnection(connStr);
live.Open();
return live;
}
private static SqliteAuditWriter CreateInMemorySqliteWriter() =>
new(
Options.Create(new SqliteAuditWriterOptions
{
DatabasePath = "ignored",
BatchSize = 64,
ChannelCapacity = 1024,
}),
NullLogger<SqliteAuditWriter>.Instance,
new FakeNodeIdentityProvider(),
connectionStringOverride:
$"Data Source=file:auditlog-execid-{Guid.NewGuid():N}?mode=memory&cache=shared");
private static IOptions<SiteAuditTelemetryOptions> FastTelemetryOptions() =>
Options.Create(new SiteAuditTelemetryOptions
{
BatchSize = 256,
// 1s on both intervals so the initial scheduled tick fires quickly
// — drains the SQLite Pending rows and pushes them through the stub
// gRPC client into the central ingest actor.
BusyIntervalSeconds = 1,
IdleIntervalSeconds = 1,
});
private IActorRef CreateIngestActor(IAuditLogRepository repo) =>
Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
repo,
NullLogger<AuditLogIngestActor>.Instance)));
private IActorRef CreateTelemetryActor(
ISiteAuditQueue queue,
ISiteStreamAuditClient client) =>
Sys.ActorOf(Props.Create(() => new SiteAuditTelemetryActor(
queue,
client,
FastTelemetryOptions(),
NullLogger<SiteAuditTelemetryActor>.Instance)));
[SkippableFact]
public async Task OneExecution_ApiCallAndDbWrite_AllCentralRows_ShareOneNonNullExecutionId()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
// An explicit per-run execution id — the value the test asserts on every
// audit row produced by the single script execution below.
var executionId = Guid.NewGuid();
// ── Central — repository + ingest actor backed by the MSSQL fixture ──
await using var ingestContext = CreateContext();
var ingestRepo = new AuditLogRepository(ingestContext);
var ingestActor = CreateIngestActor(ingestRepo);
// ── Site — SQLite audit writer + ring + fallback + telemetry actor ───
await using var sqliteWriter = CreateInMemorySqliteWriter();
var ring = new RingBufferFallback();
var fallback = new FallbackAuditWriter(
sqliteWriter,
ring,
new NoOpAuditWriteFailureCounter(),
NullLogger<FallbackAuditWriter>.Instance);
var stubClient = new DirectActorSiteStreamAuditClient(ingestActor);
CreateTelemetryActor(sqliteWriter, stubClient);
// Outbound API client — one successful CallAsync, one audit row.
var externalClient = Substitute.For<IExternalSystemClient>();
externalClient
.CallAsync("ERP", "GetOrder", Arg.Any<IReadOnlyDictionary<string, object?>?>(), Arg.Any<CancellationToken>())
.Returns(new ExternalCallResult(true, "{}", null));
// SQLite-backed inner DB connection — the stub gateway hands it to the
// auditing wrapper as the connection the script would have got.
using var keepAlive = new SqliteConnection("Data Source=execid-k1;Mode=Memory;Cache=Shared");
var innerDb = NewInMemoryDb(out _);
var gateway = Substitute.For<IDatabaseGateway>();
gateway.GetConnectionAsync(ConnectionName, Arg.Any<CancellationToken>())
.Returns(innerDb);
// ── Act — ONE script execution: a sync ExternalSystem.Call AND a sync
// Database write, both performed through a SINGLE ScriptRuntimeContext
// stamped with the explicit executionId. Each helper emits exactly one
// trust-boundary audit row to the fallback writer; the telemetry actor's
// next tick drains both to central.
var context = CreateScriptContext(externalClient, gateway, fallback, siteId, executionId);
await context.ExternalSystem.Call("ERP", "GetOrder");
await using (var conn = await context.Database.Connection(ConnectionName))
await using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "INSERT INTO t (id, name) VALUES (1, 'alpha')";
var affected = await cmd.ExecuteNonQueryAsync();
Assert.Equal(1, affected);
}
// ── Assert — read the rows back from the CENTRAL store filtered by the
// execution id; both the ApiCall and the DbWrite row must be present and
// every one must carry the SAME non-null ExecutionId we minted above.
await AwaitAssertAsync(async () =>
{
await using var readContext = CreateContext();
var readRepo = new AuditLogRepository(readContext);
// The ExecutionId filter dimension is the universal-correlation
// query an audit reader uses to pull every action of one run.
var rows = await readRepo.QueryAsync(
new AuditLogQueryFilter(ExecutionId: executionId),
new AuditLogPaging(PageSize: 10));
// Both trust-boundary actions of the one execution have landed.
Assert.Equal(2, rows.Count);
// Every central row carries the SAME non-null ExecutionId — the
// core promise of the per-run correlation value.
Assert.All(rows, r =>
{
Assert.NotNull(r.ExecutionId);
Assert.Equal(executionId, r.ExecutionId);
Assert.Equal(siteId, r.SourceSiteId);
// Central stamps IngestedAtUtc; the site never sets it.
Assert.NotNull(r.IngestedAtUtc);
});
// The two rows are the two distinct trust-boundary actions — one
// outbound API call and one outbound DB write — proving the shared
// id spans different channels, not two rows of the same action.
Assert.Single(rows, r => r.Channel == AuditChannel.ApiOutbound && r.Kind == AuditKind.ApiCall);
Assert.Single(rows, r => r.Channel == AuditChannel.DbOutbound && r.Kind == AuditKind.DbWrite);
}, TimeSpan.FromSeconds(15));
}
/// <summary>
/// Builds a production <see cref="ScriptRuntimeContext"/> wired with the
/// outbound external-system client, the database gateway and the audit
/// writer, stamped with an explicit <paramref name="executionId"/>. The
/// actor refs are <see cref="ActorRefs.Nobody"/> — the ExternalSystem /
/// Database helpers exercised here never touch them.
/// </summary>
private static ScriptRuntimeContext CreateScriptContext(
IExternalSystemClient externalSystemClient,
IDatabaseGateway databaseGateway,
IAuditWriter auditWriter,
string siteId,
Guid executionId)
{
var compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
var sharedScriptLibrary = new SharedScriptLibrary(
compilationService, NullLogger<SharedScriptLibrary>.Instance);
return new ScriptRuntimeContext(
ActorRefs.Nobody,
ActorRefs.Nobody,
sharedScriptLibrary,
currentCallDepth: 0,
maxCallDepth: 10,
askTimeout: TimeSpan.FromSeconds(5),
instanceName: InstanceName,
logger: NullLogger.Instance,
externalSystemClient: externalSystemClient,
databaseGateway: databaseGateway,
storeAndForward: null,
siteCommunicationActor: null,
siteId: siteId,
sourceScript: SourceScript,
auditWriter: auditWriter,
operationTrackingStore: null,
cachedForwarder: null,
executionId: executionId);
}
}
@@ -0,0 +1,298 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
/// <summary>
/// Audit Log #23 — M4 Bundle E (Task E3) end-to-end audit trail for the
/// inbound API surface. Wires the production
/// <see cref="AuditWriteMiddleware"/> into a Microsoft.AspNetCore.TestHost
/// pipeline that mirrors the production
/// <c>UseAuthentication → UseAuditWriteMiddleware → POST /api/{methodName}</c>
/// order, with the real <see cref="CentralAuditWriter"/> backed by the per-class
/// <see cref="MsSqlMigrationFixture"/> MSSQL <c>AuditLog</c> table.
/// </summary>
/// <remarks>
/// <para>
/// Three response shapes are covered: a happy-path 200 (with the actor
/// resolved from <see cref="HttpContext.Items"/>), a 401 unauthenticated
/// (Actor stays null, kind flips to
/// <see cref="AuditKind.InboundAuthFailure"/>), and a 500 internal-error
/// response. Each test uses a unique method name so concurrent tests sharing
/// the fixture don't interfere.
/// </para>
/// <para>
/// The middleware-level unit tests already cover the recording-writer shape
/// (<c>AuditWriteMiddlewareTests</c>) and the pipeline ordering
/// (<c>MiddlewareOrderTests</c>); these tests verify the END-TO-END
/// materialisation in the central <c>AuditLog</c> table — the production
/// glue from request → writer → repository → MSSQL row.
/// </para>
/// </remarks>
public class InboundApiAuditTests : IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public InboundApiAuditTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
/// <summary>
/// Per-test unique method name suffix — the audit row's <c>Target</c>
/// captures it so each test can query by <see cref="AuditLogQueryFilter.Target"/>
/// without disturbing other tests using the same MSSQL fixture.
/// </summary>
private static string NewMethodName(string prefix) =>
prefix + "-" + Guid.NewGuid().ToString("N").Substring(0, 8);
private ScadaBridgeDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer(_fixture.ConnectionString)
.ConfigureWarnings(w => w.Ignore(
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning))
.Options;
return new ScadaBridgeDbContext(options);
}
/// <summary>
/// Spins up a minimal in-memory ASP.NET host whose pipeline mirrors the
/// production arrangement. The endpoint handler delegate is supplied by
/// each test so it can shape the response (200 with an actor, 401
/// auth-fail, 500 server error) the way the production handler would.
/// </summary>
private async Task<IHost> BuildHostAsync(RequestDelegate endpointHandler)
{
var hostBuilder = new HostBuilder()
.ConfigureWebHost(webBuilder =>
{
webBuilder
.UseTestServer()
.ConfigureServices(services =>
{
// Real EF DbContext + AuditLogRepository wired against
// the per-class MSSQL fixture. CentralAuditWriter is a
// singleton — same pattern the production Host uses —
// opening a fresh scope per call to resolve the scoped
// repository.
services.AddDbContext<ScadaBridgeDbContext>(opts =>
opts.UseSqlServer(_fixture.ConnectionString)
.ConfigureWarnings(w => w.Ignore(
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
services.AddScoped<IAuditLogRepository>(sp =>
new AuditLogRepository(sp.GetRequiredService<ScadaBridgeDbContext>()));
services.AddSingleton<ICentralAuditWriter>(sp =>
new CentralAuditWriter(sp, NullLogger<CentralAuditWriter>.Instance));
services.AddRouting();
services.AddAuthorization();
services.AddAuthentication("TestScheme")
.AddScheme<AuthenticationSchemeOptions, AlwaysAuthenticatedHandler>(
"TestScheme", _ => { });
})
.Configure(app =>
{
// Mirror production order: routing → auth → audit
// middleware → endpoint. The auth scheme always
// succeeds; per-request auth-failure semantics are
// produced INSIDE the endpoint handler (mirroring
// ApiKeyValidator's in-handler short-circuit).
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseAuditWriteMiddleware();
app.UseEndpoints(endpoints =>
{
endpoints.MapPost("/api/{methodName}", endpointHandler);
});
});
});
return await hostBuilder.StartAsync();
}
/// <summary>
/// Minimal authentication handler that always succeeds — keeps
/// <see cref="HttpContext.User"/> populated so the middleware's
/// Items-then-User fallback path has a real principal to ignore. The
/// middleware's primary actor resolution path uses
/// <see cref="AuditWriteMiddleware.AuditActorItemKey"/> so this handler's
/// claim never appears on the emitted Actor unless the endpoint stashes
/// it explicitly.
/// </summary>
private sealed class AlwaysAuthenticatedHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public AlwaysAuthenticatedHandler(
Microsoft.Extensions.Options.IOptionsMonitor<AuthenticationSchemeOptions> options,
Microsoft.Extensions.Logging.ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder) { }
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var identity = new ClaimsIdentity(
new[] { new Claim(ClaimTypes.Name, "framework-user") }, "TestScheme");
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, "TestScheme");
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
/// <summary>
/// Queries the central <c>AuditLog</c> table for the row produced by the
/// test's unique method name. Wrapped in <see cref="Assert"/> calls so the
/// query can be used inside a polling helper.
/// </summary>
private async Task<IReadOnlyList<AuditEvent>> QueryByTargetAsync(string methodName)
{
await using var ctx = CreateContext();
var repo = new AuditLogRepository(ctx);
return await repo.QueryAsync(
new AuditLogQueryFilter(Target: methodName),
new AuditLogPaging(PageSize: 10));
}
/// <summary>
/// Awaits the central <c>AuditLog</c> row materialising for
/// <paramref name="methodName"/>. The writer is fire-and-forget so we
/// poll briefly after the HTTP response returns to absorb scheduling
/// jitter between the middleware's <c>finally</c> block and the row hitting
/// MSSQL.
/// </summary>
private async Task<AuditEvent> AwaitOneAsync(string methodName)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
while (DateTime.UtcNow < deadline)
{
var rows = await QueryByTargetAsync(methodName);
if (rows.Count > 0)
{
return Assert.Single(rows);
}
await Task.Delay(100);
}
// Fall through to a final query so the failure message carries the
// actual row count from the last attempt.
var finalRows = await QueryByTargetAsync(methodName);
return Assert.Single(finalRows);
}
[SkippableFact]
public async Task PostToApi_WithValidActor_Emits_InboundRequest_StatusDelivered_HttpStatus200_ActorPopulated()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var methodName = NewMethodName("echo");
using var host = await BuildHostAsync(async ctx =>
{
// Simulate the production endpoint stashing the resolved API key
// name on HttpContext.Items AFTER successful auth — the middleware
// reads it in its finally block to populate Actor.
ctx.Items[AuditWriteMiddleware.AuditActorItemKey] = "integration-svc";
ctx.Response.StatusCode = 200;
await ctx.Response.WriteAsync("ok");
});
var client = host.GetTestClient();
var resp = await client.PostAsync(
$"/api/{methodName}",
new StringContent("{\"x\":1}", Encoding.UTF8, "application/json"));
Assert.Equal(System.Net.HttpStatusCode.OK, resp.StatusCode);
var evt = await AwaitOneAsync(methodName);
Assert.Equal(AuditChannel.ApiInbound, evt.Channel);
Assert.Equal(AuditKind.InboundRequest, evt.Kind);
Assert.Equal(AuditStatus.Delivered, evt.Status);
Assert.Equal(200, evt.HttpStatus);
Assert.Equal("integration-svc", evt.Actor);
// Central direct-write — no site-local forward state (alog.md §6).
Assert.Null(evt.ForwardState);
// IngestedAtUtc stamped by the central writer.
Assert.NotNull(evt.IngestedAtUtc);
}
[SkippableFact]
public async Task PostToApi_Without_Auth_Emits_InboundAuthFailure_StatusFailed_HttpStatus401_ActorNull()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var methodName = NewMethodName("auth-fail");
using var host = await BuildHostAsync(async ctx =>
{
// The production ApiKeyValidator returns 401 from inside the
// handler when the X-API-Key header is missing or invalid; the
// handler must NOT stash an actor name in that case so the
// middleware emits Actor=null on the resulting audit row.
ctx.Response.StatusCode = 401;
await ctx.Response.WriteAsync("unauthorized");
});
var client = host.GetTestClient();
var resp = await client.PostAsync(
$"/api/{methodName}",
new StringContent("{}", Encoding.UTF8, "application/json"));
Assert.Equal(System.Net.HttpStatusCode.Unauthorized, resp.StatusCode);
var evt = await AwaitOneAsync(methodName);
Assert.Equal(AuditChannel.ApiInbound, evt.Channel);
Assert.Equal(AuditKind.InboundAuthFailure, evt.Kind);
Assert.Equal(AuditStatus.Failed, evt.Status);
Assert.Equal(401, evt.HttpStatus);
// Never echo back an unauthenticated principal — middleware suppresses
// the framework user resolution on 401/403 paths.
Assert.Null(evt.Actor);
}
[SkippableFact]
public async Task PostToApi_Returning500_Emits_InboundRequest_StatusFailed_HttpStatus500()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var methodName = NewMethodName("server-error");
using var host = await BuildHostAsync(async ctx =>
{
// A handler-returned 500 (not a throw) — auth succeeded so Actor
// resolution is still allowed; the audit row's Kind stays
// InboundRequest (not InboundAuthFailure) and Status flips to
// Failed because the response is not a 2xx.
ctx.Items[AuditWriteMiddleware.AuditActorItemKey] = "integration-svc";
ctx.Response.StatusCode = 500;
await ctx.Response.WriteAsync("kaboom");
});
var client = host.GetTestClient();
var resp = await client.PostAsync(
$"/api/{methodName}",
new StringContent("{}", Encoding.UTF8, "application/json"));
Assert.Equal(System.Net.HttpStatusCode.InternalServerError, resp.StatusCode);
var evt = await AwaitOneAsync(methodName);
Assert.Equal(AuditChannel.ApiInbound, evt.Channel);
Assert.Equal(AuditKind.InboundRequest, evt.Kind);
Assert.Equal(AuditStatus.Failed, evt.Status);
Assert.Equal(500, evt.HttpStatus);
Assert.Equal("integration-svc", evt.Actor);
}
}
@@ -0,0 +1,125 @@
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
using Google.Protobuf.WellKnownTypes;
using Timestamp = Google.Protobuf.WellKnownTypes.Timestamp;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
/// <summary>
/// Test-side combined-telemetry dispatcher: wraps a production
/// <see cref="ICachedCallTelemetryForwarder"/> so the local audit + tracking
/// stores still get written, then projects the same packet onto the wire as a
/// <see cref="CachedTelemetryBatch"/> and pushes it through the supplied
/// <see cref="ISiteStreamAuditClient"/>. The bridge can be composed into the
/// existing <see cref="CachedCallLifecycleBridge"/> chain as the
/// <see cref="ICachedCallTelemetryForwarder"/> implementation so a single
/// observer callback fans out to both halves.
/// </summary>
/// <remarks>
/// <para>
/// Production wiring keeps the wire push deferred to M6 — the site SQLite hot
/// path is the source of truth and a future M6 drain will push the rows
/// through the gRPC client. For end-to-end testing today we need a way to
/// exercise the central dual-write transaction immediately, so this
/// dispatcher synthesises the wire packet inline and round-trips it through
/// the stub client. The shape mirrors what the M6 drain will eventually emit.
/// </para>
/// <para>
/// <b>Best-effort:</b> both the inner forwarder call and the wire push are
/// wrapped in independent try/catch blocks. A thrown wire client doesn't
/// abort the local writes (the audit row is already in SQLite); a thrown
/// local forwarder doesn't abort the wire push (central still gets the
/// dual-write attempt).
/// </para>
/// </remarks>
public sealed class CombinedTelemetryDispatcher : ICachedCallTelemetryForwarder
{
private readonly ICachedCallTelemetryForwarder _inner;
private readonly ISiteStreamAuditClient _wireClient;
public CombinedTelemetryDispatcher(
ICachedCallTelemetryForwarder inner,
ISiteStreamAuditClient wireClient)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
_wireClient = wireClient ?? throw new ArgumentNullException(nameof(wireClient));
}
/// <inheritdoc/>
public async Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(telemetry);
// Inner forwarder writes the audit row to SQLite + updates the
// tracking store. Best-effort — exceptions are already swallowed
// inside the production forwarder, but wrap defensively here too in
// case a test substitutes a stricter inner.
try
{
await _inner.ForwardAsync(telemetry, ct).ConfigureAwait(false);
}
catch
{
// Swallow — alog.md §7 best-effort contract.
}
// Project the same packet onto the wire and push it through the stub
// client. This is the bit a future M6 drain will subsume — until
// then the test wraps the two halves into one observer-driven step.
try
{
var batch = new CachedTelemetryBatch();
batch.Packets.Add(BuildPacket(telemetry));
await _wireClient.IngestCachedTelemetryAsync(batch, ct).ConfigureAwait(false);
}
catch
{
// Swallow — the audit row is still in SQLite for a future drain;
// the central row will materialise the next time the wire path
// is exercised (or via the M6 reconciliation pull).
}
}
private static CachedTelemetryPacket BuildPacket(CachedCallTelemetry telemetry)
{
return new CachedTelemetryPacket
{
AuditEvent = AuditEventDtoMapper.ToDto(telemetry.Audit),
Operational = ToOperationalDto(telemetry.Operational),
};
}
private static SiteCallOperationalDto ToOperationalDto(SiteCallOperational op)
{
var dto = new SiteCallOperationalDto
{
TrackedOperationId = op.TrackedOperationId.Value.ToString("D"),
Channel = op.Channel,
Target = op.Target,
SourceSite = op.SourceSite,
SourceNode = op.SourceNode ?? string.Empty,
Status = op.Status,
RetryCount = op.RetryCount,
LastError = op.LastError ?? string.Empty,
CreatedAtUtc = Timestamp.FromDateTime(EnsureUtc(op.CreatedAtUtc)),
UpdatedAtUtc = Timestamp.FromDateTime(EnsureUtc(op.UpdatedAtUtc)),
};
if (op.HttpStatus.HasValue)
{
dto.HttpStatus = op.HttpStatus.Value;
}
if (op.TerminalAtUtc.HasValue)
{
dto.TerminalAtUtc = Timestamp.FromDateTime(EnsureUtc(op.TerminalAtUtc.Value));
}
return dto;
}
private static DateTime EnsureUtc(DateTime value) =>
value.Kind == DateTimeKind.Utc
? value
: DateTime.SpecifyKind(value.ToUniversalTime(), DateTimeKind.Utc);
}
@@ -0,0 +1,177 @@
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tracking;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
/// <summary>
/// Shared end-to-end harness for the M3 cached-call combined telemetry tests
/// (G2/G3/G4). Composes the full pipeline:
/// <list type="bullet">
/// <item><description>Site-local SQLite <see cref="SqliteAuditWriter"/> (in-memory) +
/// <see cref="RingBufferFallback"/> + <see cref="FallbackAuditWriter"/>.</description></item>
/// <item><description>Site-local SQLite <see cref="OperationTrackingStore"/> (in-memory).</description></item>
/// <item><description>Production <see cref="CachedCallTelemetryForwarder"/> wrapped by a
/// test-side <see cref="CombinedTelemetryDispatcher"/> that also pushes each
/// packet through the stub gRPC client.</description></item>
/// <item><description><see cref="CachedCallLifecycleBridge"/> wired to the
/// dispatcher so a single observer call fans out audit + tracking + wire.</description></item>
/// <item><description><see cref="DirectActorSiteStreamAuditClient"/> connected
/// to an <see cref="AuditLogIngestActor"/> backed by the real
/// <see cref="AuditLogRepository"/> + <see cref="SiteCallAuditRepository"/>
/// against the per-test <see cref="MsSqlMigrationFixture"/> database.</description></item>
/// </list>
/// </summary>
/// <remarks>
/// <para>
/// Disposal cleans up the in-memory SQLite stores. The Akka actor system is
/// owned by the calling <see cref="Akka.TestKit.Xunit2.TestKit"/>; the harness
/// only owns the ingest actor IActorRef and the underlying repositories'
/// DbContext lifecycle.
/// </para>
/// </remarks>
public sealed class CombinedTelemetryHarness : IAsyncDisposable
{
public SqliteAuditWriter SqliteWriter { get; }
public RingBufferFallback Ring { get; }
public FallbackAuditWriter FallbackWriter { get; }
public OperationTrackingStore TrackingStore { get; }
public CachedCallTelemetryForwarder InnerForwarder { get; }
public CombinedTelemetryDispatcher Dispatcher { get; }
public CachedCallLifecycleBridge Bridge { get; }
public DirectActorSiteStreamAuditClient StubClient { get; }
public IActorRef IngestActor { get; }
public IServiceProvider ServiceProvider { get; }
private readonly MsSqlMigrationFixture _fixture;
private bool _disposed;
public CombinedTelemetryHarness(
MsSqlMigrationFixture fixture,
Akka.TestKit.Xunit2.TestKit testKit,
Func<ScadaBridgeDbContext, ISiteCallAuditRepository>? siteCallRepoOverride = null)
{
_fixture = fixture ?? throw new ArgumentNullException(nameof(fixture));
ArgumentNullException.ThrowIfNull(testKit);
// Site SQLite — unique in-memory database per harness so tests don't share
// an audit queue. Mode=Memory + Cache=Shared keeps the file alive for the
// lifetime of the writer connection.
SqliteWriter = new SqliteAuditWriter(
Options.Create(new SqliteAuditWriterOptions
{
DatabasePath = "ignored",
BatchSize = 64,
ChannelCapacity = 1024,
}),
NullLogger<SqliteAuditWriter>.Instance,
new FakeNodeIdentityProvider(),
connectionStringOverride:
$"Data Source=file:cachedcall-g-{Guid.NewGuid():N}?mode=memory&cache=shared");
Ring = new RingBufferFallback();
FallbackWriter = new FallbackAuditWriter(
SqliteWriter, Ring, new NoOpAuditWriteFailureCounter(),
NullLogger<FallbackAuditWriter>.Instance);
TrackingStore = new OperationTrackingStore(
Options.Create(new OperationTrackingOptions
{
// Same shared-in-memory pattern as the audit writer.
ConnectionString =
$"Data Source=file:tracking-g-{Guid.NewGuid():N}?mode=memory&cache=shared",
}),
NullLogger<OperationTrackingStore>.Instance);
// Central wiring: real repositories backed by the MSSQL fixture's DB.
ServiceProvider = BuildCentralServiceProvider(siteCallRepoOverride);
IngestActor = testKit.Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
ServiceProvider,
NullLogger<AuditLogIngestActor>.Instance)));
StubClient = new DirectActorSiteStreamAuditClient(IngestActor);
// Production forwarder writes the local stores; the dispatcher wraps
// it to ALSO push the same packet to central via the stub client.
InnerForwarder = new CachedCallTelemetryForwarder(
FallbackWriter, TrackingStore, NullLogger<CachedCallTelemetryForwarder>.Instance);
Dispatcher = new CombinedTelemetryDispatcher(InnerForwarder, StubClient);
Bridge = new CachedCallLifecycleBridge(Dispatcher, NullLogger<CachedCallLifecycleBridge>.Instance);
}
/// <summary>
/// Convenience: emit the initial submit packet directly through the
/// dispatcher (the bridge's hooks fire only for S&amp;F retry-loop
/// attempts; submit-row emission happens at the script call site).
/// </summary>
public Task EmitSubmitAsync(CachedCallTelemetry submit, CancellationToken ct = default) =>
Dispatcher.ForwardAsync(submit, ct);
/// <summary>
/// Convenience: route a per-attempt or terminal outcome through the bridge.
/// </summary>
public Task EmitAttemptAsync(CachedCallAttemptContext context, CancellationToken ct = default) =>
Bridge.OnAttemptCompletedAsync(context, ct);
public ScadaBridgeDbContext CreateReadContext()
{
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer(_fixture.ConnectionString)
.Options;
return new ScadaBridgeDbContext(options);
}
private IServiceProvider BuildCentralServiceProvider(
Func<ScadaBridgeDbContext, ISiteCallAuditRepository>? siteCallRepoOverride)
{
var services = new ServiceCollection();
services.AddDbContext<ScadaBridgeDbContext>(opts =>
opts.UseSqlServer(_fixture.ConnectionString)
.ConfigureWarnings(w => w.Ignore(
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
services.AddScoped<IAuditLogRepository>(sp =>
new AuditLogRepository(sp.GetRequiredService<ScadaBridgeDbContext>()));
if (siteCallRepoOverride is null)
{
services.AddScoped<ISiteCallAuditRepository>(sp =>
new SiteCallAuditRepository(sp.GetRequiredService<ScadaBridgeDbContext>()));
}
else
{
services.AddScoped(sp =>
siteCallRepoOverride(sp.GetRequiredService<ScadaBridgeDbContext>()));
}
return services.BuildServiceProvider();
}
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
await SqliteWriter.DisposeAsync().ConfigureAwait(false);
await TrackingStore.DisposeAsync().ConfigureAwait(false);
if (ServiceProvider is IAsyncDisposable asyncSp)
{
await asyncSp.DisposeAsync().ConfigureAwait(false);
}
else if (ServiceProvider is IDisposable sp)
{
sp.Dispose();
}
}
}
@@ -0,0 +1,150 @@
using Akka.Actor;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
/// <summary>
/// Shared component-level <see cref="ISiteStreamAuditClient"/> test double that
/// short-circuits the gRPC wire and forwards each batch directly to a central
/// <see cref="AuditLog.Central.AuditLogIngestActor"/> via Akka <see cref="Futures.Ask"/>.
/// Lives under <c>Integration/Infrastructure/</c> so both the M2 sync-call and
/// M3 cached-call end-to-end suites can reuse it.
/// </summary>
/// <remarks>
/// <para>
/// The class deliberately mirrors the production <c>SiteStreamGrpcServer</c>
/// flow: decode each DTO into the in-process entity, Ask the central ingest
/// actor with the matching Akka command, and convert the Akka reply's accepted
/// id list into the proto <see cref="IngestAck"/> the telemetry actor / forwarder
/// expects. The actor wiring (single-repository vs. <see cref="IServiceProvider"/>
/// ctor) lives in the central ingest actor itself — this stub just routes the
/// command.
/// </para>
/// <para>
/// <see cref="FailNextCallCount"/> arms a deterministic number of failures
/// before the stub recovers; it applies to BOTH RPCs because the M2 sync-call
/// retry behaviour and the M3 cached-telemetry retry behaviour share a single
/// SiteAuditTelemetryActor drain. Tests that need to differentiate per-RPC
/// failures should reach for a per-test wrapper rather than extending this
/// shared infrastructure.
/// </para>
/// </remarks>
public sealed class DirectActorSiteStreamAuditClient : ISiteStreamAuditClient
{
private readonly IActorRef _ingestActor;
private int _failsRemaining;
private int _callCount;
private int _cachedTelemetryCallCount;
public DirectActorSiteStreamAuditClient(IActorRef ingestActor)
{
_ingestActor = ingestActor ?? throw new ArgumentNullException(nameof(ingestActor));
}
/// <summary>
/// When &gt; 0, the next <c>FailNextCallCount</c> invocations of either
/// RPC throw to simulate a gRPC error; after that count is exhausted, calls
/// succeed normally.
/// </summary>
public int FailNextCallCount
{
get => _failsRemaining;
set => _failsRemaining = value;
}
/// <summary>
/// Total successful + failed invocations of <see cref="IngestAuditEventsAsync"/>.
/// </summary>
public int CallCount => Volatile.Read(ref _callCount);
/// <summary>
/// Total successful + failed invocations of <see cref="IngestCachedTelemetryAsync"/>.
/// Separate counter so cached-call tests can assert dispatch independently of
/// any sync-call traffic going through the same stub.
/// </summary>
public int CachedTelemetryCallCount => Volatile.Read(ref _cachedTelemetryCallCount);
public async Task<IngestAck> IngestAuditEventsAsync(AuditEventBatch batch, CancellationToken ct)
{
Interlocked.Increment(ref _callCount);
// Atomically consume one of the queued failures, if any. This lets the
// test arm a deterministic number of failures before the stub recovers.
if (Interlocked.Decrement(ref _failsRemaining) >= 0)
{
throw new InvalidOperationException("simulated gRPC failure for test");
}
// Clamp at -1 to keep the field bounded under many calls.
Interlocked.Exchange(ref _failsRemaining, -1);
// Decode the proto batch back into AuditEvent records — mirrors what
// SiteStreamGrpcServer does before dispatching to the ingest actor.
var events = new List<AuditEvent>(batch.Events.Count);
foreach (var dto in batch.Events)
{
events.Add(AuditEventDtoMapper.FromDto(dto));
}
// Ask the central actor; the reply carries the accepted EventIds.
var reply = await _ingestActor
.Ask<IngestAuditEventsReply>(
new IngestAuditEventsCommand(events),
TimeSpan.FromSeconds(10))
.ConfigureAwait(false);
var ack = new IngestAck();
foreach (var id in reply.AcceptedEventIds)
{
ack.AcceptedEventIds.Add(id.ToString());
}
return ack;
}
/// <summary>
/// M3 dual-write path: decode each <see cref="CachedTelemetryPacket"/> into
/// the paired (<see cref="AuditEvent"/>, <see cref="SiteCall"/>) entry and
/// Ask the central ingest actor with an <see cref="IngestCachedTelemetryCommand"/>.
/// The accepted EventIds returned by the actor's dual-write transaction map
/// back into the proto ack.
/// </summary>
/// <remarks>
/// Uses the shared <see cref="AuditEventDtoMapper.FromDto"/> for the audit half
/// and <see cref="SiteCallDtoMapper.FromDto"/> for the SiteCall half — the same
/// canonical mappers the production <c>SiteStreamGrpcServer</c> uses.
/// </remarks>
public async Task<IngestAck> IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct)
{
Interlocked.Increment(ref _cachedTelemetryCallCount);
if (Interlocked.Decrement(ref _failsRemaining) >= 0)
{
throw new InvalidOperationException("simulated gRPC failure for test");
}
Interlocked.Exchange(ref _failsRemaining, -1);
var entries = new List<CachedTelemetryEntry>(batch.Packets.Count);
foreach (var packet in batch.Packets)
{
var audit = AuditEventDtoMapper.FromDto(packet.AuditEvent);
var siteCall = SiteCallDtoMapper.FromDto(packet.Operational);
entries.Add(new CachedTelemetryEntry(audit, siteCall));
}
var reply = await _ingestActor
.Ask<IngestCachedTelemetryReply>(
new IngestCachedTelemetryCommand(entries),
TimeSpan.FromSeconds(10))
.ConfigureAwait(false);
var ack = new IngestAck();
foreach (var id in reply.AcceptedEventIds)
{
ack.AcceptedEventIds.Add(id.ToString());
}
return ack;
}
}
@@ -0,0 +1,352 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Messages;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
/// <summary>
/// Audit Log #23 — M4 Bundle E (Task E2): end-to-end audit trail produced by
/// the central <see cref="NotificationOutboxActor"/> dispatcher loop. Wires
/// the production <see cref="CentralAuditWriter"/> onto the real
/// <see cref="AuditLogRepository"/> against the per-class
/// <see cref="MsSqlMigrationFixture"/> MSSQL database, drives the dispatcher
/// with a stub <see cref="INotificationDeliveryAdapter"/> that yields a
/// transient-then-success sequence, and asserts the resulting
/// <see cref="AuditChannel.Notification"/>/<see cref="AuditKind.NotifyDeliver"/>
/// rows materialise with the expected Attempted/Delivered shape.
/// </summary>
/// <remarks>
/// <para>
/// The Submit row is normally produced by the site-side <c>Notify.Send</c>
/// wrapper (Bundle C); for this E2E we pre-insert a single AuditLog Submit row
/// via <see cref="IAuditLogRepository"/> alongside the seeded
/// <see cref="Notification"/> row so the assertions can confirm the dispatcher
/// emissions slot in alongside it. This keeps the test focused on the
/// dispatcher's emission shape without depending on the upstream site path.
/// </para>
/// <para>
/// Each test uses a unique notification id + source-site id so concurrent
/// tests sharing the MSSQL fixture don't interfere. The dispatcher is driven
/// deterministically via the internal
/// <c>InternalMessages.DispatchTick.Instance</c> sentinel (same pattern the
/// existing NotificationOutbox.Tests use).
/// </para>
/// </remarks>
public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public NotifyDispatcherAuditTrailTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
private static string NewSiteId() =>
"test-e2-notify-" + Guid.NewGuid().ToString("N").Substring(0, 8);
private ScadaBridgeDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer(_fixture.ConnectionString)
.ConfigureWarnings(w => w.Ignore(
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning))
.Options;
return new ScadaBridgeDbContext(options);
}
/// <summary>
/// Builds a DI provider that mirrors the production wiring expected by
/// <see cref="NotificationOutboxActor"/>: scoped EF-backed
/// <see cref="INotificationOutboxRepository"/> + <see cref="INotificationRepository"/>
/// + the supplied <see cref="INotificationDeliveryAdapter"/>. The
/// <see cref="IAuditLogRepository"/> registration powers the
/// <see cref="CentralAuditWriter"/> the actor will emit through.
/// </summary>
private IServiceProvider BuildServiceProvider(INotificationDeliveryAdapter adapter)
{
var services = new ServiceCollection();
services.AddDbContext<ScadaBridgeDbContext>(opts =>
opts.UseSqlServer(_fixture.ConnectionString)
.ConfigureWarnings(w => w.Ignore(
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
services.AddScoped<INotificationOutboxRepository>(sp =>
new NotificationOutboxRepository(sp.GetRequiredService<ScadaBridgeDbContext>()));
services.AddScoped<INotificationRepository>(sp =>
new NotificationRepository(sp.GetRequiredService<ScadaBridgeDbContext>()));
services.AddScoped<IAuditLogRepository>(sp =>
new AuditLogRepository(sp.GetRequiredService<ScadaBridgeDbContext>()));
services.AddScoped<INotificationDeliveryAdapter>(_ => adapter);
return services.BuildServiceProvider();
}
/// <summary>
/// Stub adapter that yields the next outcome from a configurable queue per
/// call. Lets a single dispatch sweep exercise the transient-then-success
/// transition by alternating <see cref="DeliveryResult.TransientFailure"/>
/// and <see cref="DeliveryResult.Success"/>.
/// </summary>
private sealed class QueuedOutcomeAdapter : INotificationDeliveryAdapter
{
private readonly Queue<DeliveryOutcome> _outcomes;
public int CallCount;
public QueuedOutcomeAdapter(params DeliveryOutcome[] outcomes)
{
_outcomes = new Queue<DeliveryOutcome>(outcomes);
}
public NotificationType Type => NotificationType.Email;
public Task<DeliveryOutcome> DeliverAsync(
Notification notification, CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref CallCount);
// Defensive — if a test under-supplies outcomes we surface the
// problem as an explicit transient failure rather than throwing
// (the dispatcher would log + skip the notification but the audit
// assertions would be misleading).
var outcome = _outcomes.Count > 0
? _outcomes.Dequeue()
: DeliveryOutcome.Transient("test stub out of outcomes");
return Task.FromResult(outcome);
}
}
/// <summary>
/// Inserts a single SMTP configuration row so the dispatcher's
/// <c>ResolveRetryPolicyAsync</c> sees a real (maxRetries, retryDelay)
/// pair rather than the conservative fallback. A tiny positive RetryDelay
/// means a transient outcome's <c>NextAttemptAt</c> is immediately due —
/// useful so the SECOND DispatchTick re-claims the row without waiting.
/// NO-002: the dispatcher now clamps a non-positive RetryDelay to the
/// 1-minute fallback to avoid burn-looping on transient failures, so this
/// must be a strictly positive value (1 ms is fine for tests).
/// </summary>
private async Task SeedSmtpConfigAsync(int maxRetries = 5)
{
await using var ctx = CreateContext();
ctx.SmtpConfigurations.Add(new SmtpConfiguration(
"smtp.example.com", "Basic", "noreply@example.com")
{
MaxRetries = maxRetries,
RetryDelay = TimeSpan.FromMilliseconds(1),
});
await ctx.SaveChangesAsync();
}
/// <summary>
/// Seeds the Pending outbox row the dispatcher will claim. Using a fixed
/// caller-supplied <c>notificationId</c> so the test can later query the
/// AuditLog by <see cref="AuditEvent.CorrelationId"/> = notificationId.
/// </summary>
private async Task<Notification> SeedNotificationAsync(
Guid notificationId, string siteId, string listName = "ops-team")
{
await using var ctx = CreateContext();
var n = new Notification(
notificationId.ToString("D"),
NotificationType.Email,
listName,
"Tank overflow",
"Tank 3 level critical",
siteId)
{
SourceInstanceId = "Plant.Pump42",
SourceScript = "AlarmScript",
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-1),
};
ctx.Notifications.Add(n);
await ctx.SaveChangesAsync();
return n;
}
/// <summary>
/// Pre-inserts the Submit AuditLog row that the site-side Notify.Send
/// wrapper would have emitted (Bundle C). Keeps the assertions on the
/// dispatcher emissions intact without depending on the upstream site
/// path.
/// </summary>
private async Task SeedSubmitAuditRowAsync(Guid notificationId, string siteId)
{
await using var ctx = CreateContext();
var repo = new AuditLogRepository(ctx);
var submitEvt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow.AddMinutes(-1),
Channel = AuditChannel.Notification,
Kind = AuditKind.NotifySend,
CorrelationId = notificationId,
SourceSiteId = siteId,
SourceInstanceId = "Plant.Pump42",
SourceScript = "AlarmScript",
Target = "ops-team",
Status = AuditStatus.Submitted,
ForwardState = AuditForwardState.Forwarded,
IngestedAtUtc = DateTime.UtcNow.AddMinutes(-1),
};
await repo.InsertIfNotExistsAsync(submitEvt);
}
private static NotificationOutboxOptions LongDispatchOptions() =>
// 1h dispatch + 24h purge so PreStart's timers never fire during the
// test; the test drives the dispatcher with explicit DispatchTick.
new()
{
DispatchInterval = TimeSpan.FromHours(1),
PurgeInterval = TimeSpan.FromDays(1),
};
[SkippableFact]
public async Task NotifyDispatcher_FailThenSuccess_Emits_TwoAttempts_OneDelivered_Terminal()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var notificationId = Guid.NewGuid();
await SeedSmtpConfigAsync(maxRetries: 5);
await SeedNotificationAsync(notificationId, siteId);
await SeedSubmitAuditRowAsync(notificationId, siteId);
var adapter = new QueuedOutcomeAdapter(
DeliveryOutcome.Transient("smtp 421 try again"),
DeliveryOutcome.Success("ops@example.com"));
var serviceProvider = BuildServiceProvider(adapter);
var auditWriter = new CentralAuditWriter(
serviceProvider,
NullLogger<CentralAuditWriter>.Instance);
var actor = Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
serviceProvider,
LongDispatchOptions(),
(ICentralAuditWriter)auditWriter,
NullLogger<NotificationOutboxActor>.Instance)));
// First tick: transient failure → one Attempted row, no terminal row.
actor.Tell(InternalMessages.DispatchTick.Instance);
await AwaitAssertAsync(async () =>
{
await using var ctx = CreateContext();
var repo = new AuditLogRepository(ctx);
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 50));
// 1 Submit + 1 Attempted = 2 rows so far.
Assert.Equal(2, rows.Count);
Assert.Single(rows, r => r.Kind == AuditKind.NotifyDeliver
&& r.Status == AuditStatus.Attempted);
Assert.Single(rows, r => r.Kind == AuditKind.NotifySend);
}, TimeSpan.FromSeconds(15));
// Second tick: success → second Attempted + one Delivered terminal.
actor.Tell(InternalMessages.DispatchTick.Instance);
await AwaitAssertAsync(async () =>
{
await using var ctx = CreateContext();
var repo = new AuditLogRepository(ctx);
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 50));
// 1 Submit + 2 Attempted + 1 Delivered terminal = 4 rows.
Assert.InRange(rows.Count, 3, 4);
var notifyDeliverRows = rows
.Where(r => r.Kind == AuditKind.NotifyDeliver)
.ToList();
Assert.Equal(2, notifyDeliverRows.Count(r => r.Status == AuditStatus.Attempted));
var terminal = Assert.Single(notifyDeliverRows, r => r.Status == AuditStatus.Delivered);
// All NotifyDeliver rows correlate to the original notification id.
Assert.All(notifyDeliverRows, r => Assert.Equal(notificationId, r.CorrelationId));
Assert.Equal("ops-team", terminal.Target);
}, TimeSpan.FromSeconds(15));
// Operational Notifications table mirrors the audit outcome.
await AwaitAssertAsync(async () =>
{
await using var ctx = CreateContext();
var n = await ctx.Notifications.SingleAsync(
row => row.NotificationId == notificationId.ToString("D"));
Assert.Equal(NotificationStatus.Delivered, n.Status);
Assert.NotNull(n.DeliveredAt);
}, TimeSpan.FromSeconds(15));
}
[SkippableFact]
public async Task NotifyDispatcher_AuditWriter_Throws_DeliveryStillSucceeds()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var notificationId = Guid.NewGuid();
await SeedSmtpConfigAsync(maxRetries: 5);
await SeedNotificationAsync(notificationId, siteId);
var adapter = new QueuedOutcomeAdapter(
DeliveryOutcome.Success("ops@example.com"));
var serviceProvider = BuildServiceProvider(adapter);
// ALWAYS-throw writer wired in place of the production
// CentralAuditWriter. The dispatcher MUST still deliver the
// notification and persist the terminal Delivered transition
// regardless of the audit subsystem being down (alog.md §13).
var throwingWriter = new ThrowingCentralAuditWriter();
var actor = Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
serviceProvider,
LongDispatchOptions(),
(ICentralAuditWriter)throwingWriter,
NullLogger<NotificationOutboxActor>.Instance)));
actor.Tell(InternalMessages.DispatchTick.Instance);
// The Notifications table is the operational source of truth — assert
// it transitions to Delivered even though every audit write threw.
await AwaitAssertAsync(async () =>
{
await using var ctx = CreateContext();
var n = await ctx.Notifications.SingleAsync(
row => row.NotificationId == notificationId.ToString("D"));
Assert.Equal(NotificationStatus.Delivered, n.Status);
Assert.NotNull(n.DeliveredAt);
}, TimeSpan.FromSeconds(15));
// The writer was attempted (at least once for the Attempted row, plus
// once for the Delivered terminal) — proves the dispatcher tried to
// emit and absorbed the throws rather than aborting the action.
Assert.True(throwingWriter.AttemptCount >= 2,
$"Expected the dispatcher to attempt audit writes; saw {throwingWriter.AttemptCount}");
}
/// <summary>
/// Test-only <see cref="ICentralAuditWriter"/> that ALWAYS throws on
/// <see cref="WriteAsync"/>. Used to verify the dispatcher's defensive
/// try/catch contract (alog.md §13) — audit failures must NEVER abort
/// the user-facing notification delivery.
/// </summary>
private sealed class ThrowingCentralAuditWriter : ICentralAuditWriter
{
private int _attemptCount;
public int AttemptCount => Volatile.Read(ref _attemptCount);
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
Interlocked.Increment(ref _attemptCount);
throw new InvalidOperationException(
"test-only ThrowingCentralAuditWriter — audit subsystem unavailable");
}
}
}
@@ -0,0 +1,351 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
/// <summary>
/// Bundle F (#23 M6-T10) end-to-end test for the central-outage + reconciliation
/// recovery loop. Wires the real site SQLite hot-path
/// (<see cref="SqliteAuditWriter"/>) and the central <see cref="SiteAuditReconciliationActor"/>
/// with an <see cref="AuditLogIngestActor"/> backed by the real
/// <see cref="AuditLogRepository"/> on the per-test <see cref="MsSqlMigrationFixture"/>.
/// </summary>
/// <remarks>
/// <para>
/// The push path is deliberately omitted here: the brief models a sustained
/// central outage where the site queue grows unbounded in Pending, then a
/// reconciliation pull eventually drains everything once central comes back.
/// We reuse the production <see cref="IPullAuditEventsClient"/> seam (Bundle B)
/// with a test-only stub that wraps the same <see cref="ISiteAuditQueue.ReadPendingSinceAsync"/>
/// surface a real central-side gRPC client would hit, so the test is exercising
/// the actor's pull/ingest/mark-reconciled state machine end-to-end against
/// the real repository.
/// </para>
/// <para>
/// The <see cref="CombinedTelemetryHarness"/> from M3 is push-only — it has no
/// reconciliation puller — so we build the smaller stub inline rather than
/// retrofitting the shared harness with a code path it doesn't otherwise
/// need.
/// </para>
/// </remarks>
public class OutageReconciliationTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public OutageReconciliationTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
/// <summary>
/// Test-only <see cref="IPullAuditEventsClient"/> that mirrors how the
/// production central-side gRPC client will hit the site: read a batch
/// from <see cref="ISiteAuditQueue.ReadPendingSinceAsync"/>, then commit
/// via <see cref="ISiteAuditQueue.MarkReconciledAsync"/> once the central
/// repository accepts the rows. The Ask-based central path is wired by
/// the caller — we just expose the queue surface.
/// </summary>
/// <remarks>
/// The production wire shape will be:
/// central PullAuditEvents RPC → site SiteStreamGrpcServer.PullAuditEvents
/// → ISiteAuditQueue.ReadPendingSinceAsync → marshal proto → reply
/// followed by central InsertIfNotExistsAsync per row, then the site flips
/// the row to Reconciled on the next pull cycle. The stub collapses the
/// two halves (pull + commit) because the actor under test (the
/// reconciliation actor) is the side that drives both via the
/// IPullAuditEventsClient seam — committing back to the site after the
/// repository write is the reconciliation-actor invariant we want to
/// observe end-to-end.
/// </remarks>
private sealed class QueueBackedPullClient : IPullAuditEventsClient
{
private readonly ISiteAuditQueue _siteQueue;
public int CallCount { get; private set; }
public QueueBackedPullClient(ISiteAuditQueue siteQueue)
{
_siteQueue = siteQueue ?? throw new ArgumentNullException(nameof(siteQueue));
}
public async Task<PullAuditEventsResponse> PullAsync(
string siteId, DateTime sinceUtc, int batchSize, CancellationToken ct)
{
CallCount++;
var rows = await _siteQueue
.ReadPendingSinceAsync(sinceUtc, batchSize, ct)
.ConfigureAwait(false);
// Commit immediately on the site side — once the actor has the
// batch in hand it will InsertIfNotExistsAsync centrally; if the
// central insert later throws on a specific row, idempotency
// guarantees the next pull cycle does NOT re-fetch the row (it's
// already Reconciled on the site) but also does not surface the
// failure here. The brief calls this "ack-after-persist" — the
// production gRPC server will flip to Reconciled inside its
// PullAuditEvents handler after the central side has acknowledged
// (per Bundle A's race-fix, central is idempotent on EventId).
//
// MoreAvailable is true iff the read filled the batch — the actor
// uses this to decide whether to follow up on the next tick.
if (rows.Count > 0)
{
var ids = rows.Select(e => e.EventId).ToList();
await _siteQueue.MarkReconciledAsync(ids, ct).ConfigureAwait(false);
}
return new PullAuditEventsResponse(rows, MoreAvailable: rows.Count >= batchSize);
}
}
/// <summary>
/// In-memory enumerator returning a fixed single-site list — mirrors the
/// pattern used in <c>SiteAuditReconciliationActorTests</c>.
/// </summary>
private sealed class StaticEnumerator : ISiteEnumerator
{
private readonly IReadOnlyList<SiteEntry> _sites;
public StaticEnumerator(params SiteEntry[] sites) => _sites = sites;
public Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default) =>
Task.FromResult(_sites);
}
private ScadaBridgeDbContext CreateContext() =>
new(new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer(_fixture.ConnectionString).Options);
private static AuditEvent NewEvent(string siteId, DateTime occurredAt) => new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = occurredAt,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
SourceSiteId = siteId,
Target = "external-system-a/method",
};
private SqliteAuditWriter CreateInMemorySqliteWriter() =>
new SqliteAuditWriter(
Options.Create(new SqliteAuditWriterOptions
{
DatabasePath = "ignored",
BatchSize = 64,
ChannelCapacity = 4096,
}),
NullLogger<SqliteAuditWriter>.Instance,
new FakeNodeIdentityProvider(),
connectionStringOverride:
$"Data Source=file:outage-{Guid.NewGuid():N}?mode=memory&cache=shared");
private (IServiceProvider Sp, IActorRef Ingest) BuildCentralPipeline()
{
var services = new ServiceCollection();
services.AddDbContext<ScadaBridgeDbContext>(opts =>
opts.UseSqlServer(_fixture.ConnectionString));
services.AddScoped<IAuditLogRepository>(sp =>
new AuditLogRepository(sp.GetRequiredService<ScadaBridgeDbContext>()));
var sp = services.BuildServiceProvider();
var ingest = Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
sp,
NullLogger<AuditLogIngestActor>.Instance)));
return (sp, ingest);
}
private static SiteAuditReconciliationOptions FastTickOptions(int batchSize = 256) => new()
{
ReconciliationIntervalSeconds = 300,
ReconciliationIntervalOverride = TimeSpan.FromMilliseconds(100),
BatchSize = batchSize,
StalledAfterNonDrainingCycles = 2,
};
// ---------------------------------------------------------------------
// 1. CentralOutage_200Events_Buffer_Then_Reconciliation_Catches_Up_NoDuplicates
// ---------------------------------------------------------------------
[SkippableFact]
public async Task CentralOutage_200Events_Buffer_Then_Reconciliation_Catches_Up_NoDuplicates()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = "outage-recon-" + Guid.NewGuid().ToString("N").Substring(0, 8);
// Step 1: site accumulates 200 audit events during the simulated
// central outage. The push path is NOT wired here — every row stays
// Pending in the site SQLite store until reconciliation runs.
await using var sqliteWriter = CreateInMemorySqliteWriter();
var baseOccurred = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc);
const int totalEvents = 200;
var written = new List<AuditEvent>(totalEvents);
for (int i = 0; i < totalEvents; i++)
{
// Strictly monotonic OccurredAtUtc so the cursor can advance
// deterministically batch-by-batch — mirrors how a real script
// workload generates timestamps in wall-clock order.
var evt = NewEvent(siteId, baseOccurred.AddMilliseconds(i));
written.Add(evt);
await sqliteWriter.WriteAsync(evt);
}
// Sanity: every row is Pending (no push path wired, so nothing has
// been Forwarded or Reconciled yet).
var pending = await sqliteWriter.ReadPendingAsync(totalEvents + 10);
Assert.Equal(totalEvents, pending.Count);
// Step 2: central comes online — wire the ingest actor + reconciliation
// actor. The pull client wraps the site queue directly (the production
// shape is one RPC call); each pull advances the actor's cursor and
// flips rows on the site to Reconciled.
var (sp, ingest) = BuildCentralPipeline();
await using (sp as IAsyncDisposable ?? throw new InvalidOperationException())
{
var pullClient = new QueueBackedPullClient(sqliteWriter);
var enumerator = new StaticEnumerator(new SiteEntry(siteId, "http://test:8083"));
// BatchSize = 64 so the actor needs ~4 ticks to drain 200 rows.
// The "after 5 minutes" wording in the brief is satisfied by the
// fast-tick override (100 ms per tick) plus AwaitAssert giving
// the actor up to ~30 seconds to settle in real time.
var opts = FastTickOptions(batchSize: 64);
// Standalone DI scope for the reconciliation actor (it shares the
// ingest actor's IServiceProvider so both writers see the same
// EF context configuration).
var reconciliationActor = Sys.ActorOf(Props.Create(() => new SiteAuditReconciliationActor(
enumerator,
pullClient,
sp,
Options.Create(opts),
NullLogger<SiteAuditReconciliationActor>.Instance)));
// Step 3: assert central AuditLog has all 200 rows after the
// actor drains. Polling the real MSSQL repository — the test
// fixture has its own database so a count restricted to this
// SourceSiteId is exact.
await AwaitAssertAsync(async () =>
{
await using var ctx = CreateContext();
var count = await ctx.Set<AuditEvent>()
.Where(e => e.SourceSiteId == siteId)
.CountAsync();
Assert.Equal(totalEvents, count);
},
duration: TimeSpan.FromSeconds(30),
interval: TimeSpan.FromMilliseconds(200));
// Step 4: assert site rows flipped to Reconciled.
// ReadPendingAsync only returns Pending rows; after a full drain
// it must be empty.
await AwaitAssertAsync(async () =>
{
var stillPending = await sqliteWriter.ReadPendingAsync(totalEvents + 10);
Assert.Empty(stillPending);
},
duration: TimeSpan.FromSeconds(10),
interval: TimeSpan.FromMilliseconds(100));
// Step 5: assert no duplicates by EventId — central must have
// exactly the 200 rows we wrote at the site (one row per EventId).
await using var verify = CreateContext();
var centralIds = await verify.Set<AuditEvent>()
.Where(e => e.SourceSiteId == siteId)
.Select(e => e.EventId)
.ToListAsync();
Assert.Equal(totalEvents, centralIds.Count);
Assert.Equal(totalEvents, centralIds.Distinct().Count());
// And every EventId we wrote at the site is present centrally.
Assert.True(written.All(w => centralIds.Contains(w.EventId)),
"every site-written EventId should be present centrally.");
// Tear the actor down before disposing the harness; the actor's
// PostStop cancels its scheduled timer.
Sys.Stop(reconciliationActor);
}
}
// ---------------------------------------------------------------------
// 2. ReconciliationPull_Idempotent_Across_Two_Cycles
// ---------------------------------------------------------------------
[SkippableFact]
public async Task ReconciliationPull_Idempotent_Across_Two_Cycles()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = "outage-idem-" + Guid.NewGuid().ToString("N").Substring(0, 8);
const int totalEvents = 50;
await using var sqliteWriter = CreateInMemorySqliteWriter();
var baseOccurred = new DateTime(2026, 5, 20, 13, 0, 0, DateTimeKind.Utc);
for (int i = 0; i < totalEvents; i++)
{
await sqliteWriter.WriteAsync(NewEvent(siteId, baseOccurred.AddMilliseconds(i)));
}
var (sp, _) = BuildCentralPipeline();
await using (sp as IAsyncDisposable ?? throw new InvalidOperationException())
{
var pullClient = new QueueBackedPullClient(sqliteWriter);
var enumerator = new StaticEnumerator(new SiteEntry(siteId, "http://test:8083"));
var reconciliationActor = Sys.ActorOf(Props.Create(() => new SiteAuditReconciliationActor(
enumerator,
pullClient,
sp,
Options.Create(FastTickOptions()),
NullLogger<SiteAuditReconciliationActor>.Instance)));
// Wait for the first drain cycle to complete.
await AwaitAssertAsync(async () =>
{
await using var ctx = CreateContext();
var count = await ctx.Set<AuditEvent>()
.Where(e => e.SourceSiteId == siteId)
.CountAsync();
Assert.Equal(totalEvents, count);
},
duration: TimeSpan.FromSeconds(30),
interval: TimeSpan.FromMilliseconds(200));
// Wait for additional pull cycles to fire — the actor ticks every
// 100 ms so a 1 s settle leaves the actor with at least ~5 ticks
// past the initial drain. Each subsequent tick must be a no-op
// because every row is now Reconciled and outside the
// ReadPendingSinceAsync filter.
var callsAfterDrain = pullClient.CallCount;
await Task.Delay(TimeSpan.FromMilliseconds(800));
Assert.True(pullClient.CallCount > callsAfterDrain,
$"expected additional pull calls after drain to validate idempotency, got {pullClient.CallCount} after {callsAfterDrain}");
// Central count must still be exactly totalEvents — no duplicates
// even though the cursor + read-Reconciled-too semantics could
// theoretically re-fetch on the second cycle.
await using var verify = CreateContext();
var rows = await verify.Set<AuditEvent>()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
Assert.Equal(totalEvents, rows.Count);
Assert.Equal(totalEvents, rows.Select(r => r.EventId).Distinct().Count());
Sys.Stop(reconciliationActor);
}
}
}
@@ -0,0 +1,626 @@
using System.Text;
using System.Text.Json;
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
using ZB.MOM.WW.ScadaBridge.InboundAPI;
using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Messages;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.StoreAndForward;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
/// <summary>
/// Audit Log #23 — <b>ParentExecutionId cross-execution correlation</b> headline
/// end-to-end suite. Verifies the inbound-API → routed-site-script bridge: an
/// inbound HTTP request runs an inbound method script that calls
/// <c>Route.Call</c> into a site instance; the routed site script does a sync
/// <c>ExternalSystem.Call</c>, a cached call and a <c>Notify.Send</c>. Every
/// audit row the routed run produces — site + central, sync + cached lifecycle
/// + <c>NotifySend</c>/<c>NotifyDeliver</c> — must carry
/// <see cref="AuditEvent.ParentExecutionId"/> equal to the inbound request's
/// <see cref="AuditEvent.ExecutionId"/>, while the routed run has its own
/// distinct <see cref="AuditEvent.ExecutionId"/> and the inbound
/// <see cref="AuditKind.InboundRequest"/> row is top-level
/// (<c>ParentExecutionId = NULL</c>).
/// </summary>
/// <remarks>
/// <para>
/// This is the integration-level counterpart to <see cref="ExecutionIdCorrelationTests"/>:
/// where that suite drives a single <see cref="ScriptRuntimeContext"/> run and
/// asserts the shared per-run <c>ExecutionId</c>, this suite spans <b>two</b>
/// executions on opposite sides of the inbound→routed bridge and asserts the
/// cross-execution <c>ParentExecutionId</c> linkage plus
/// <see cref="IAuditLogRepository.GetExecutionTreeAsync"/>.
/// </para>
/// <para>
/// The bridge is exercised through the genuine production glue:
/// <list type="bullet">
/// <item><description>the real <see cref="AuditWriteMiddleware"/> in a
/// Microsoft.AspNetCore.TestHost pipeline — mints the inbound request's
/// per-request <c>ExecutionId</c> once, stashes it on
/// <see cref="HttpContext.Items"/>, and emits the top-level
/// <see cref="AuditKind.InboundRequest"/> row via the real
/// <see cref="CentralAuditWriter"/>;</description></item>
/// <item><description>the real <see cref="InboundScriptExecutor"/> +
/// <see cref="RouteHelper"/> — the executor binds the stashed inbound
/// <c>ExecutionId</c> via <see cref="RouteHelper.WithParentExecutionId"/>, so a
/// <c>Route.To(...).Call(...)</c> inside the inbound script builds a
/// <see cref="RouteToCallRequest"/> carrying
/// <see cref="RouteToCallRequest.ParentExecutionId"/>.</description></item>
/// </list>
/// Only the cross-cluster routing transport is substituted: the test
/// <see cref="BridgingInstanceRouter"/> stands in for
/// <c>CommunicationServiceInstanceRouter</c> exactly as the production site
/// (<c>DeploymentManagerActor</c> → <c>ScriptActor</c> → <c>ScriptExecutionActor</c>)
/// would — it reads <see cref="RouteToCallRequest.ParentExecutionId"/> off the
/// wire request and threads it into the routed <see cref="ScriptRuntimeContext"/>
/// as <c>parentExecutionId</c>. A multi-node cluster is out of scope for an
/// in-process test (mirroring <c>SiteAuditPushFlowTests</c>'s relay).
/// </para>
/// <para>
/// The central audit store is the real <see cref="AuditLogRepository"/> over the
/// per-class <see cref="MsSqlMigrationFixture"/> MSSQL database; the routed run's
/// site rows reach it through the real <see cref="SqliteAuditWriter"/> hot-path +
/// <see cref="SiteAuditTelemetryActor"/> drain, the cached lifecycle rows through
/// the production <see cref="CachedCallTelemetryForwarder"/>, and the
/// <c>NotifyDeliver</c> rows through the real central
/// <see cref="NotificationOutboxActor"/> dispatcher.
/// </para>
/// </remarks>
public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public ParentExecutionIdCorrelationTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
private const string RoutedInstanceCode = "Plant.Pump42";
private const string RoutedScriptName = "OnInboundRouted";
private const string ExternalSystemName = "ERP";
private const string ExternalMethodName = "GetOrder";
private const string NotifyListName = "ops-team";
/// <summary>Per-run site id (Guid suffix) so concurrent tests sharing the MSSQL fixture stay isolated.</summary>
private static string NewSiteId() =>
"test-parentexec-" + Guid.NewGuid().ToString("N").Substring(0, 8);
private ScadaBridgeDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer(_fixture.ConnectionString)
.ConfigureWarnings(w => w.Ignore(
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning))
.Options;
return new ScadaBridgeDbContext(options);
}
[SkippableFact]
public async Task InboundRoutedRun_AllRoutedRows_CarryInboundExecutionId_AsParentExecutionId()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
// ── Central — repository + ingest actor + audit writer over the MSSQL fixture ──
var centralServices = new ServiceCollection();
centralServices.AddDbContext<ScadaBridgeDbContext>(opts =>
opts.UseSqlServer(_fixture.ConnectionString)
.ConfigureWarnings(w => w.Ignore(
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
centralServices.AddScoped<IAuditLogRepository>(sp =>
new AuditLogRepository(sp.GetRequiredService<ScadaBridgeDbContext>()));
centralServices.AddScoped<ISiteCallAuditRepository>(sp =>
new SiteCallAuditRepository(sp.GetRequiredService<ScadaBridgeDbContext>()));
centralServices.AddScoped<INotificationOutboxRepository>(sp =>
new NotificationOutboxRepository(sp.GetRequiredService<ScadaBridgeDbContext>()));
centralServices.AddScoped<INotificationRepository>(sp =>
new NotificationRepository(sp.GetRequiredService<ScadaBridgeDbContext>()));
// The NotifyDeliver dispatch path runs through this same long-lived
// provider — a stub adapter that always reports a successful delivery.
centralServices.AddScoped<INotificationDeliveryAdapter>(_ => new AlwaysDeliversAdapter());
await using var centralProvider = centralServices.BuildServiceProvider();
var ingestActor = Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
(IServiceProvider)centralProvider,
NullLogger<AuditLogIngestActor>.Instance)));
var centralAuditWriter = new CentralAuditWriter(
centralProvider, NullLogger<CentralAuditWriter>.Instance);
// ── Site — SQLite audit writer (hot-path) drained to central by the
// real SiteAuditTelemetryActor through the stub gRPC client. The sync
// ApiCall row and the NotifySend row flow through this chain. ──
await using var sqliteWriter = new SqliteAuditWriter(
Options.Create(new SqliteAuditWriterOptions
{
DatabasePath = "ignored",
BatchSize = 64,
ChannelCapacity = 1024,
}),
NullLogger<SqliteAuditWriter>.Instance,
new FakeNodeIdentityProvider(),
connectionStringOverride:
$"Data Source=file:auditlog-parentexec-{Guid.NewGuid():N}?mode=memory&cache=shared");
var ring = new RingBufferFallback();
var siteAuditWriter = new FallbackAuditWriter(
sqliteWriter, ring, new NoOpAuditWriteFailureCounter(),
NullLogger<FallbackAuditWriter>.Instance);
var stubClient = new DirectActorSiteStreamAuditClient(ingestActor);
Sys.ActorOf(Props.Create(() => new SiteAuditTelemetryActor(
(ISiteAuditQueue)sqliteWriter,
stubClient,
Options.Create(new SiteAuditTelemetryOptions
{
BatchSize = 256,
BusyIntervalSeconds = 1,
IdleIntervalSeconds = 1,
}),
NullLogger<SiteAuditTelemetryActor>.Instance)));
// Cached-call telemetry: production forwarder + dispatcher that also
// pushes each combined packet through the stub client into the central
// dual-write transaction (same wiring CombinedTelemetryHarness uses).
var cachedForwarder = new CombinedTelemetryDispatcher(
new CachedCallTelemetryForwarder(
siteAuditWriter, trackingStore: null,
NullLogger<CachedCallTelemetryForwarder>.Instance),
stubClient);
// Site Store-and-Forward — Notify.Send buffers a NotificationSubmit here.
using var safKeepAlive = new Microsoft.Data.Sqlite.SqliteConnection(
$"Data Source=parentexec-saf-{Guid.NewGuid():N};Mode=Memory;Cache=Shared");
safKeepAlive.Open();
var safStorage = new StoreAndForwardStorage(
safKeepAlive.ConnectionString, NullLogger<StoreAndForwardStorage>.Instance);
await safStorage.InitializeAsync();
var storeAndForward = new StoreAndForwardService(
safStorage,
new StoreAndForwardOptions
{
DefaultRetryInterval = TimeSpan.Zero,
DefaultMaxRetries = 3,
RetryTimerInterval = TimeSpan.FromMinutes(10),
},
NullLogger<StoreAndForwardService>.Instance);
// ── Outbound external-system client (routed run): sync Call succeeds,
// CachedCall completes immediately (WasBuffered=false) so the script
// helper emits the Submit + Attempted + CachedResolve lifecycle. ──
var externalClient = Substitute.For<IExternalSystemClient>();
externalClient
.CallAsync(ExternalSystemName, ExternalMethodName,
Arg.Any<IReadOnlyDictionary<string, object?>?>(), Arg.Any<CancellationToken>())
.Returns(new ExternalCallResult(true, "{\"ok\":true}", null));
externalClient
.CachedCallAsync(ExternalSystemName, ExternalMethodName,
Arg.Any<IReadOnlyDictionary<string, object?>?>(),
Arg.Any<string?>(), Arg.Any<CancellationToken>(),
Arg.Any<ZB.MOM.WW.ScadaBridge.Commons.Types.TrackedOperationId?>(),
Arg.Any<Guid?>(), Arg.Any<string?>(), Arg.Any<Guid?>())
.Returns(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false));
// ── The routing transport stand-in: builds the routed ScriptRuntimeContext
// carrying RouteToCallRequest.ParentExecutionId — exactly what the
// production site handler (DeploymentManagerActor) does. ──
var router = new BridgingInstanceRouter(
siteId,
externalClient,
siteAuditWriter,
cachedForwarder,
storeAndForward);
// ── The inbound API method script: it calls Route.Call into the site
// instance. The real InboundScriptExecutor binds the inbound request's
// ExecutionId onto the RouteHelper, so the routed call carries it as
// ParentExecutionId. ──
var inboundMethod = new ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi.ApiMethod(
"submitOrder",
$"return await Route.To(\"{RoutedInstanceCode}\").Call(\"{RoutedScriptName}\", new {{ order = 7 }});");
var locator = Substitute.For<IInstanceLocator>();
locator.GetSiteIdForInstanceAsync(RoutedInstanceCode, Arg.Any<CancellationToken>())
.Returns(siteId);
var scriptExecutor = new InboundScriptExecutor(
NullLogger<InboundScriptExecutor>.Instance,
new ServiceCollection().BuildServiceProvider());
Assert.True(scriptExecutor.CompileAndRegister(inboundMethod));
// ── Act — issue the inbound HTTP request through a TestHost pipeline
// fronted by the real AuditWriteMiddleware. The endpoint handler reads
// the middleware-stashed inbound ExecutionId and runs the inbound
// method script with it as parentExecutionId. ──
using var host = await BuildInboundHostAsync(centralAuditWriter, async ctx =>
{
var inboundExecutionId = (Guid)ctx.Items[AuditWriteMiddleware.InboundExecutionIdItemKey]!;
var route = new RouteHelper(locator, router);
var result = await scriptExecutor.ExecuteAsync(
inboundMethod,
new Dictionary<string, object?>(),
route,
TimeSpan.FromSeconds(30),
ctx.RequestAborted,
parentExecutionId: inboundExecutionId);
ctx.Response.StatusCode = result.Success ? 200 : 500;
await ctx.Response.WriteAsync(result.Success ? "ok" : "fail");
});
var client = host.GetTestClient();
var response = await client.PostAsync(
"/api/submitOrder",
new StringContent("{}", Encoding.UTF8, "application/json"));
Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode);
// The routed run emits its sync-ApiCall and NotifySend audit rows on a
// deliberately fire-and-forget path (alog.md §7 — an audit write must
// never block the user-facing script call). `Notify.Send` therefore
// returns — and the routed `RouteToCallAsync` completes — BEFORE the
// SqliteAuditWriter background loop has flushed the NotifySend row into
// the site hot-path. Wait for all five site rows to be durably present
// in SQLite before the central assertion: this is the production
// durability point (the row IS in SQLite before it is considered
// audited), and pinning it removes the emit-vs-drain race that
// otherwise let the SiteAuditTelemetryADrain forward only four rows on
// its first tick and leave NotifySend stranded for a full drain
// interval under heavy parallel load.
await WaitForSiteRowsPersistedAsync(sqliteWriter);
// The routed run produced a NotifySend that buffered a NotificationSubmit
// into S&F. Drain that genuine site-produced submission to the central
// NotificationOutboxActor so the NotifyDeliver dispatch rows materialise.
await ForwardBufferedNotificationToCentralAsync(
storeAndForward, router.NotificationId!, centralProvider, centralAuditWriter);
// ── Assert ──────────────────────────────────────────────────────────
await AwaitAssertAsync(async () =>
{
await using var readContext = CreateContext();
var repo = new AuditLogRepository(readContext);
// Every audit row this site produced (sync ApiCall + cached lifecycle
// + NotifySend) plus the central NotifyDeliver rows.
var siteRows = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 100));
// sync ApiCall (1) + cached Submit/Attempted/Resolve (3) + NotifySend (1)
// + NotifyDeliver Attempted/Delivered (2) = 7 rows for the routed run.
Assert.True(siteRows.Count == 7,
"Expected 7 routed-run audit rows; saw: "
+ string.Join(", ", siteRows.Select(r => $"{r.Channel}/{r.Kind}/{r.Status}")));
Assert.Single(siteRows, r => r.Channel == AuditChannel.ApiOutbound && r.Kind == AuditKind.ApiCall);
Assert.Single(siteRows, r => r.Kind == AuditKind.CachedSubmit);
Assert.Single(siteRows, r => r.Kind == AuditKind.CachedResolve);
Assert.Single(siteRows, r => r.Kind == AuditKind.NotifySend);
Assert.Equal(2, siteRows.Count(r => r.Kind == AuditKind.NotifyDeliver));
// CORE PROMISE: every routed-run row carries the SAME non-null
// ParentExecutionId — the inbound request's ExecutionId.
var parentIds = siteRows.Select(r => r.ParentExecutionId).Distinct().ToList();
Assert.Single(parentIds);
Assert.NotNull(parentIds[0]);
var inboundExecutionId = parentIds[0]!.Value;
// The routed run has its OWN distinct ExecutionId — not the parent's.
var routedExecutionIds = siteRows
.Select(r => r.ExecutionId)
.Distinct()
.ToList();
Assert.Single(routedExecutionIds);
Assert.NotNull(routedExecutionIds[0]);
var routedExecutionId = routedExecutionIds[0]!.Value;
Assert.NotEqual(inboundExecutionId, routedExecutionId);
// The inbound request's own InboundRequest row is TOP-LEVEL —
// ExecutionId = the propagated id, ParentExecutionId = NULL.
var inboundRows = await repo.QueryAsync(
new AuditLogQueryFilter(ExecutionId: inboundExecutionId),
new AuditLogPaging(PageSize: 10));
var inboundRow = Assert.Single(inboundRows,
r => r.Channel == AuditChannel.ApiInbound && r.Kind == AuditKind.InboundRequest);
Assert.Equal(AuditStatus.Delivered, inboundRow.Status);
Assert.Null(inboundRow.ParentExecutionId);
// The parentExecutionId filter pulls the routed run's complete
// trust-boundary footprint (all 7 routed rows, none of the inbound).
var byParent = await repo.QueryAsync(
new AuditLogQueryFilter(ParentExecutionId: inboundExecutionId),
new AuditLogPaging(PageSize: 100));
Assert.Equal(7, byParent.Count);
Assert.All(byParent, r => Assert.Equal(routedExecutionId, r.ExecutionId));
// GetExecutionTreeAsync returns BOTH executions in one chain —
// inbound (root) and routed (child), regardless of entry point.
var treeFromChild = await repo.GetExecutionTreeAsync(routedExecutionId);
AssertChain(treeFromChild, inboundExecutionId, routedExecutionId);
var treeFromRoot = await repo.GetExecutionTreeAsync(inboundExecutionId);
AssertChain(treeFromRoot, inboundExecutionId, routedExecutionId);
}, TimeSpan.FromSeconds(90));
}
/// <summary>
/// Asserts the execution tree is the expected two-node inbound→routed chain:
/// the inbound execution is the root (<c>ParentExecutionId = NULL</c>) and the
/// routed execution's <c>ParentExecutionId</c> points back at it.
/// </summary>
private static void AssertChain(
IReadOnlyList<ExecutionTreeNode> tree,
Guid inboundExecutionId,
Guid routedExecutionId)
{
Assert.Equal(2, tree.Count);
var root = Assert.Single(tree, n => n.ExecutionId == inboundExecutionId);
Assert.Null(root.ParentExecutionId);
var child = Assert.Single(tree, n => n.ExecutionId == routedExecutionId);
Assert.Equal(inboundExecutionId, child.ParentExecutionId);
}
/// <summary>
/// Spins up a minimal in-memory ASP.NET host whose pipeline mirrors the
/// production inbound-API arrangement: routing → the real
/// <see cref="AuditWriteMiddleware"/> → the <c>POST /api/{methodName}</c>
/// endpoint. The middleware mints + stashes the inbound request's
/// <c>ExecutionId</c> and emits the top-level <see cref="AuditKind.InboundRequest"/>
/// row via the supplied <see cref="ICentralAuditWriter"/>.
/// </summary>
private static async Task<IHost> BuildInboundHostAsync(
ICentralAuditWriter centralAuditWriter,
RequestDelegate endpointHandler)
{
var hostBuilder = new HostBuilder()
.ConfigureWebHost(webBuilder =>
{
webBuilder
.UseTestServer()
.ConfigureServices(services =>
{
services.AddSingleton(centralAuditWriter);
services.AddRouting();
})
.Configure(app =>
{
app.UseRouting();
app.UseAuditWriteMiddleware();
app.UseEndpoints(endpoints =>
{
endpoints.MapPost("/api/{methodName}", endpointHandler);
});
});
});
return await hostBuilder.StartAsync();
}
/// <summary>
/// Reads the genuine site-produced <see cref="NotificationSubmit"/> the routed
/// <c>Notify.Send</c> buffered into Store-and-Forward, then drives it through
/// a real central <see cref="NotificationOutboxActor"/> so the
/// <see cref="AuditKind.NotifyDeliver"/> dispatch rows materialise. The
/// dispatcher echoes <c>OriginParentExecutionId</c> off the
/// <c>NotificationSubmit</c> onto every <c>NotifyDeliver</c> row — the
/// cross-execution linkage under test on the central side.
/// </summary>
private async Task ForwardBufferedNotificationToCentralAsync(
StoreAndForwardService storeAndForward,
string notificationId,
IServiceProvider centralProvider,
ICentralAuditWriter centralAuditWriter)
{
var buffered = await storeAndForward.GetMessageByIdAsync(notificationId);
Assert.NotNull(buffered);
var submit = JsonSerializer.Deserialize<NotificationSubmit>(buffered!.PayloadJson);
Assert.NotNull(submit);
// The routed Notify.Send stamped the inbound request's ExecutionId as the
// submission's OriginParentExecutionId — proven separately on the
// NotifyDeliver rows, but asserted here too as the central handoff input.
Assert.NotNull(submit!.OriginParentExecutionId);
// The outbox actor runs over the long-lived central provider (which
// carries the AlwaysDeliversAdapter) so the dispatch sweep — launched
// asynchronously by the DispatchTick — still has a live IServiceProvider
// to resolve its per-sweep scope from.
var outboxActor = Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
centralProvider,
new NotificationOutboxOptions
{
// Long timers so PreStart's scheduled ticks never fire — the
// test drives ingest + dispatch explicitly.
DispatchInterval = TimeSpan.FromHours(1),
PurgeInterval = TimeSpan.FromDays(1),
},
centralAuditWriter,
NullLogger<NotificationOutboxActor>.Instance)));
// Ingest the genuine site submission, then run one dispatch sweep.
var ack = await outboxActor.Ask<NotificationSubmitAck>(
submit, TimeSpan.FromSeconds(15));
Assert.True(ack.Accepted, ack.Error);
outboxActor.Tell(InternalMessages.DispatchTick.Instance);
}
/// <summary>
/// Polls the site SQLite hot-path until every audit <see cref="AuditKind"/>
/// the routed run is expected to emit — sync <c>ApiCall</c>, the cached
/// <c>CachedSubmit</c>/<c>ApiCallCached</c>/<c>CachedResolve</c> lifecycle,
/// and <c>NotifySend</c> — is durably present (Pending or Forwarded).
/// </summary>
/// <remarks>
/// The routed run's sync-<c>ApiCall</c> and <c>NotifySend</c> audit rows are
/// written fire-and-forget (the script call must not block on the audit
/// writer — alog.md §7), so the routed <c>RouteToCallAsync</c> returns
/// before the background writer loop has committed those rows.
/// <c>NotifySend</c> is emitted last and therefore settles last. This wait
/// asserts the specific <b>Kinds</b> are present, not merely a row count: a
/// bare count could be satisfied while the last-emitted <c>NotifySend</c>
/// row was still in flight, letting the <c>SiteAuditTelemetryActor</c> drain
/// only a partial snapshot and leave <c>NotifySend</c> stranded for a later
/// tick — the emit-vs-drain race that failed this test under full-suite load.
/// </remarks>
private async Task WaitForSiteRowsPersistedAsync(SqliteAuditWriter sqliteWriter)
{
var expectedKinds = new[]
{
AuditKind.ApiCall, AuditKind.CachedSubmit, AuditKind.ApiCallCached,
AuditKind.CachedResolve, AuditKind.NotifySend,
};
await AwaitAssertAsync(
async () =>
{
var pending = await sqliteWriter.ReadPendingAsync(256);
// AuditLog-001: ReadPendingAsync now excludes the cached-lifecycle
// kinds (they ride the combined-telemetry drain), so we union
// them in via the dedicated read surface to keep the durability
// assertion covering EVERY expected Kind.
var pendingCached = await sqliteWriter.ReadPendingCachedTelemetryAsync(256);
var forwarded = await sqliteWriter.ReadForwardedAsync(256);
var kinds = pending.Concat(pendingCached).Concat(forwarded)
.Select(r => r.Kind).ToHashSet();
var missing = expectedKinds.Where(k => !kinds.Contains(k)).ToList();
Assert.True(
missing.Count == 0,
"Expected every routed-run audit Kind durably in SQLite; missing: "
+ string.Join(", ", missing)
+ $" (saw {pending.Count} Pending + {pendingCached.Count} PendingCached + {forwarded.Count} Forwarded).");
},
TimeSpan.FromSeconds(30),
TimeSpan.FromMilliseconds(50));
}
/// <summary>
/// Stub <see cref="INotificationDeliveryAdapter"/> that always reports a
/// successful delivery — a single dispatch sweep then yields one
/// <see cref="AuditStatus.Attempted"/> + one <see cref="AuditStatus.Delivered"/>
/// <see cref="AuditKind.NotifyDeliver"/> row.
/// </summary>
private sealed class AlwaysDeliversAdapter : INotificationDeliveryAdapter
{
public NotificationType Type => NotificationType.Email;
public Task<DeliveryOutcome> DeliverAsync(
ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.Notification notification,
CancellationToken cancellationToken = default)
=> Task.FromResult(DeliveryOutcome.Success("ops@example.com"));
}
/// <summary>
/// In-process stand-in for the cross-cluster routing transport
/// (<c>CommunicationServiceInstanceRouter</c> →
/// <c>CommunicationService</c> → site <c>DeploymentManagerActor</c>). On a
/// routed <c>Call</c> it does exactly what the production site handler does:
/// it reads <see cref="RouteToCallRequest.ParentExecutionId"/> off the wire
/// request and threads it into a fresh routed <see cref="ScriptRuntimeContext"/>
/// as <c>parentExecutionId</c>, then runs the routed script's three
/// trust-boundary actions (sync <c>ExternalSystem.Call</c>, a cached call and
/// a <c>Notify.Send</c>). The routed context still mints its OWN fresh
/// <c>ExecutionId</c> — only the parent pointer is inherited.
/// </summary>
private sealed class BridgingInstanceRouter : IInstanceRouter
{
private readonly string _siteId;
private readonly IExternalSystemClient _externalClient;
private readonly IAuditWriter _auditWriter;
private readonly ICachedCallTelemetryForwarder _cachedForwarder;
private readonly StoreAndForwardService _storeAndForward;
/// <summary>
/// The <c>NotificationId</c> the routed <c>Notify.Send</c> minted, captured
/// so the test can drain the buffered <see cref="NotificationSubmit"/>.
/// </summary>
public string? NotificationId { get; private set; }
public BridgingInstanceRouter(
string siteId,
IExternalSystemClient externalClient,
IAuditWriter auditWriter,
ICachedCallTelemetryForwarder cachedForwarder,
StoreAndForwardService storeAndForward)
{
_siteId = siteId;
_externalClient = externalClient;
_auditWriter = auditWriter;
_cachedForwarder = cachedForwarder;
_storeAndForward = storeAndForward;
}
public async Task<RouteToCallResponse> RouteToCallAsync(
string siteId, RouteToCallRequest request, CancellationToken cancellationToken)
{
var compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
var sharedScriptLibrary = new SharedScriptLibrary(
compilationService, NullLogger<SharedScriptLibrary>.Instance);
// Mirror DeploymentManagerActor → ScriptActor → ScriptExecutionActor:
// the routed script execution gets its OWN fresh ExecutionId, and the
// inbound request's ExecutionId arrives as ParentExecutionId.
var routedContext = new ScriptRuntimeContext(
ActorRefs.Nobody,
ActorRefs.Nobody,
sharedScriptLibrary,
currentCallDepth: 0,
maxCallDepth: 10,
askTimeout: TimeSpan.FromSeconds(5),
instanceName: request.InstanceUniqueName,
logger: NullLogger.Instance,
externalSystemClient: _externalClient,
databaseGateway: null,
storeAndForward: _storeAndForward,
siteCommunicationActor: null,
siteId: _siteId,
sourceScript: $"ScriptActor:{request.ScriptName}",
auditWriter: _auditWriter,
operationTrackingStore: null,
cachedForwarder: _cachedForwarder,
executionId: null,
parentExecutionId: request.ParentExecutionId);
// The routed site script's body: a sync ExternalSystem.Call, a cached
// call, and a Notify.Send — three distinct trust-boundary actions of
// the one routed execution.
await routedContext.ExternalSystem.Call(ExternalSystemName, ExternalMethodName);
await routedContext.ExternalSystem.CachedCall(ExternalSystemName, ExternalMethodName);
NotificationId = await routedContext.Notify
.To(NotifyListName)
.Send("Routed run alert", "inbound-routed script fired");
return new RouteToCallResponse(
request.CorrelationId, true, "routed-ok", null, DateTimeOffset.UtcNow);
}
public Task<RouteToGetAttributesResponse> RouteToGetAttributesAsync(
string siteId, RouteToGetAttributesRequest request, CancellationToken cancellationToken)
=> throw new NotSupportedException();
public Task<RouteToSetAttributesResponse> RouteToSetAttributesAsync(
string siteId, RouteToSetAttributesRequest request, CancellationToken cancellationToken)
=> throw new NotSupportedException();
}
}
@@ -0,0 +1,278 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
/// <summary>
/// Bundle F (#23 M6-T12) end-to-end tests for the
/// <see cref="AuditLogPartitionMaintenanceService"/> hosted service running
/// the real EF/MSSQL <see cref="AuditLogPartitionMaintenance"/> against the
/// per-class <see cref="MsSqlMigrationFixture"/>. The migration seeds
/// boundaries for every month Jan 2026 Dec 2027, so the eager startup tick
/// can be exercised both for the "future covered" no-op case and for the
/// "lookahead larger than covered" SPLIT case.
/// </summary>
/// <remarks>
/// Tests within this class share one fixture DB — boundaries added by one
/// test persist across the next. Each test reads the max boundary at the
/// start and computes its lookahead relative to it, mirroring the pattern
/// used by the per-component <c>AuditLogPartitionMaintenanceTests</c> in
/// <c>ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests</c>.
/// </remarks>
public class PartitionMaintenanceTests : IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public PartitionMaintenanceTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
private ScadaBridgeDbContext CreateContext() =>
new(new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer(_fixture.ConnectionString).Options);
/// <summary>
/// Builds the central-side DI graph for the hosted service: scoped EF
/// context + scoped <see cref="IPartitionMaintenance"/> matching how
/// <c>AddConfigurationDatabase</c> wires the production composition root.
/// </summary>
private ServiceProvider BuildProvider()
{
var services = new ServiceCollection();
services.AddDbContext<ScadaBridgeDbContext>(
opts => opts.UseSqlServer(_fixture.ConnectionString),
ServiceLifetime.Scoped);
services.AddScoped<IPartitionMaintenance, AuditLogPartitionMaintenance>();
return services.BuildServiceProvider();
}
private static async Task<DateTime?> ReadMaxBoundaryAsync(IServiceProvider sp)
{
await using var scope = sp.CreateAsyncScope();
var maintenance = scope.ServiceProvider.GetRequiredService<IPartitionMaintenance>();
return await maintenance.GetMaxBoundaryAsync();
}
/// <summary>
/// Mirrors the helper in
/// <c>AuditLogPartitionMaintenanceTests.LookaheadForExtraBoundaries</c>:
/// the smallest lookahead value that lands the SPLIT horizon exactly
/// <paramref name="extraBoundaries"/> months past the current max.
/// </summary>
private static int LookaheadForExtraBoundaries(DateTime max, int extraBoundaries)
{
var nowFirstOfNextMonth = FirstOfNextMonth(DateTime.UtcNow);
var monthsToMax = ((max.Year - nowFirstOfNextMonth.Year) * 12)
+ max.Month - nowFirstOfNextMonth.Month;
return monthsToMax + extraBoundaries;
}
private static int LookaheadInsideExistingRange(DateTime max)
{
var now = DateTime.UtcNow;
var months = ((max.Year - now.Year) * 12) + max.Month - now.Month - 1;
return Math.Max(1, months);
}
private static DateTime FirstOfNextMonth(DateTime instant)
{
var firstOfThisMonth = new DateTime(instant.Year, instant.Month, 1, 0, 0, 0, DateTimeKind.Utc);
return firstOfThisMonth.AddMonths(1);
}
/// <summary>
/// Awaits one full tick of the hosted service. The service runs an
/// eager startup tick inside <see cref="AuditLogPartitionMaintenanceService.StartAsync"/>'s
/// continuation, but the continuation is dispatched on a background
/// Task.Run — so we poll the side effect (the boundary count or
/// max-boundary value) until it changes.
/// </summary>
private async Task StartAndAwaitStartupTickAsync(
AuditLogPartitionMaintenanceService svc,
Func<Task<bool>> awaitCondition,
TimeSpan timeout)
{
await svc.StartAsync(CancellationToken.None);
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (await awaitCondition())
{
return;
}
await Task.Delay(50);
}
}
// ---------------------------------------------------------------------
// 1. EndToEnd_DefaultLookahead_NoSplit_WhenFutureCovered
// ---------------------------------------------------------------------
[SkippableFact]
public async Task EndToEnd_DefaultLookahead_NoSplit_WhenFutureCovered()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
await using var sp = BuildProvider();
// The migration seeds boundaries through Dec 2027. With default
// lookahead = 1 and today = ~2026-05-20, horizon =
// NormalizeToFirstOfMonth(now) + 1 = 2026-07-01, well within the
// seeded range, so the startup tick should issue zero SPLITs.
var maxBefore = await ReadMaxBoundaryAsync(sp);
Assert.NotNull(maxBefore);
// Skip if the fixture DB already has boundaries past Dec 2027 from
// a prior test in this class — the lookahead-already-covered path
// is what we want to exercise, regardless of how far past Dec 2027
// the boundary may be.
var opts = Options.Create(new AuditLogPartitionMaintenanceOptions
{
IntervalSeconds = 60, // long enough that only the startup tick fires inside the test window
LookaheadMonths = 1,
});
var svc = new AuditLogPartitionMaintenanceService(
sp.GetRequiredService<IServiceScopeFactory>(),
opts,
NullLogger<AuditLogPartitionMaintenanceService>.Instance);
// Drive the startup tick. There is no public completion handle;
// poll until either (a) the max boundary changes (which would be a
// failure for this test) or (b) the polling window expires (success).
await svc.StartAsync(CancellationToken.None);
await Task.Delay(TimeSpan.FromSeconds(2));
await svc.StopAsync(CancellationToken.None);
svc.Dispose();
// Assert the max boundary is unchanged: no SPLIT was issued.
var maxAfter = await ReadMaxBoundaryAsync(sp);
Assert.Equal(maxBefore, maxAfter);
}
// ---------------------------------------------------------------------
// 2. EndToEnd_LookaheadLargerThanCovered_Splits_NewBoundaries
// ---------------------------------------------------------------------
[SkippableFact]
public async Task EndToEnd_LookaheadLargerThanCovered_Splits_NewBoundaries()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
await using var sp = BuildProvider();
var maxBefore = await ReadMaxBoundaryAsync(sp);
Assert.NotNull(maxBefore);
// Pick a lookahead that adds exactly two new boundaries past the
// current max. The expected new boundaries are max+1mo and max+2mo.
var lookahead = LookaheadForExtraBoundaries(maxBefore.Value, extraBoundaries: 2);
var expectedFirstNew = maxBefore.Value.AddMonths(1);
var expectedSecondNew = maxBefore.Value.AddMonths(2);
var opts = Options.Create(new AuditLogPartitionMaintenanceOptions
{
IntervalSeconds = 60,
LookaheadMonths = lookahead,
});
var svc = new AuditLogPartitionMaintenanceService(
sp.GetRequiredService<IServiceScopeFactory>(),
opts,
NullLogger<AuditLogPartitionMaintenanceService>.Instance);
// Drive the startup tick. Wait until max boundary moves forward by
// the expected amount; SPLIT against MSSQL can take a second or two
// on a busy dev container.
await StartAndAwaitStartupTickAsync(
svc,
async () =>
{
var current = await ReadMaxBoundaryAsync(sp);
return current == expectedSecondNew;
},
timeout: TimeSpan.FromSeconds(15));
await svc.StopAsync(CancellationToken.None);
svc.Dispose();
var maxAfter = await ReadMaxBoundaryAsync(sp);
// Two new boundaries should be present after the startup tick. The
// hosted service does not surface the added-list directly (it logs
// only at Information), so we assert via the max-boundary delta.
Assert.Equal(expectedSecondNew, maxAfter);
// Sanity: the intermediate boundary was also added (the loop
// SPLITs every month from max+1 up to horizon, in order).
Assert.True(expectedFirstNew < expectedSecondNew);
}
// ---------------------------------------------------------------------
// 3. EndToEnd_PartitionMaintenance_Idempotent_OverTwoRuns
// ---------------------------------------------------------------------
[SkippableFact]
public async Task EndToEnd_PartitionMaintenance_Idempotent_OverTwoRuns()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
await using var sp = BuildProvider();
var maxBefore = await ReadMaxBoundaryAsync(sp);
Assert.NotNull(maxBefore);
// Add exactly one new boundary on the first run.
var lookahead = LookaheadForExtraBoundaries(maxBefore.Value, extraBoundaries: 1);
var expectedAdded = maxBefore.Value.AddMonths(1);
var opts = Options.Create(new AuditLogPartitionMaintenanceOptions
{
IntervalSeconds = 60,
LookaheadMonths = lookahead,
});
// First run.
var svc1 = new AuditLogPartitionMaintenanceService(
sp.GetRequiredService<IServiceScopeFactory>(),
opts,
NullLogger<AuditLogPartitionMaintenanceService>.Instance);
await StartAndAwaitStartupTickAsync(
svc1,
async () =>
{
var current = await ReadMaxBoundaryAsync(sp);
return current == expectedAdded;
},
timeout: TimeSpan.FromSeconds(15));
await svc1.StopAsync(CancellationToken.None);
svc1.Dispose();
var maxAfterFirst = await ReadMaxBoundaryAsync(sp);
Assert.Equal(expectedAdded, maxAfterFirst);
// Second run with the SAME lookahead value. Because the boundary
// is already covered, the EnsureLookaheadAsync call must be a
// no-op — max boundary is unchanged AND no exception is thrown.
var svc2 = new AuditLogPartitionMaintenanceService(
sp.GetRequiredService<IServiceScopeFactory>(),
opts,
NullLogger<AuditLogPartitionMaintenanceService>.Instance);
await svc2.StartAsync(CancellationToken.None);
// Wait long enough that the startup tick would have fired and
// logged any boundary addition; the boundary state must remain
// unchanged after the wait.
await Task.Delay(TimeSpan.FromSeconds(2));
await svc2.StopAsync(CancellationToken.None);
svc2.Dispose();
var maxAfterSecond = await ReadMaxBoundaryAsync(sp);
Assert.Equal(maxAfterFirst, maxAfterSecond);
}
}
@@ -0,0 +1,354 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
/// <summary>
/// Bundle F (#23 M6-T11) end-to-end test for the daily partition-switch
/// purge: seeds three monthly partitions (Jan / Feb / Mar 2026) with direct
/// INSERTs that bypass the standard repository ingest path (so the seed
/// timestamps are explicit), drives <see cref="AuditLogPurgeActor"/> against
/// the real <see cref="AuditLogRepository"/> + per-test
/// <see cref="MsSqlMigrationFixture"/> database, and asserts:
/// <list type="number">
/// <item>The oldest partition (Jan) is removed.</item>
/// <item>Newer partitions (Feb + Mar) are untouched.</item>
/// <item>The <c>UX_AuditLog_EventId</c> unique index survives the
/// drop-and-rebuild dance.</item>
/// <item><see cref="IAuditLogRepository.InsertIfNotExistsAsync"/> remains
/// idempotent against the rebuilt index after the purge.</item>
/// </list>
/// </summary>
/// <remarks>
/// The brief calls out that direct INSERTs bypass the writer role's INSERT-only
/// grant; the fixture connects as <c>sa</c> (see
/// <see cref="MsSqlMigrationFixture"/>'s default admin connection string), so
/// the seed step does not need the writer role at all. The drop-and-rebuild
/// dance itself runs under the same admin connection because the test owns
/// the database — the role granularity is exercised in the repository tests,
/// not here.
/// </remarks>
public class PartitionPurgeTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public PartitionPurgeTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
private ScadaBridgeDbContext CreateContext() =>
new(new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer(_fixture.ConnectionString).Options);
/// <summary>
/// Direct INSERT into <c>dbo.AuditLog</c> bypassing
/// <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/>. Used by the
/// seed step so the test can place rows in arbitrary partitions without
/// the repository's idempotency wrapper or ingest-stamping behaviour
/// affecting the seed payload.
/// </summary>
private async Task DirectInsertAsync(
SqlConnection conn,
Guid eventId,
DateTime occurredAtUtc,
string siteId)
{
await using var cmd = conn.CreateCommand();
cmd.CommandText = @"
INSERT INTO dbo.AuditLog
(EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId,
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status,
HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary,
ResponseSummary, PayloadTruncated, Extra, ForwardState)
VALUES
(@EventId, @OccurredAtUtc, @IngestedAtUtc, 'ApiOutbound', 'ApiCall', NULL,
@SourceSiteId, NULL, NULL, NULL, NULL, 'Delivered',
NULL, NULL, NULL, NULL, NULL,
NULL, 0, NULL, NULL);";
cmd.Parameters.Add("@EventId", System.Data.SqlDbType.UniqueIdentifier).Value = eventId;
// SqlDbType.DateTime2 with explicit Scale 7 matches the
// OccurredAtUtc column shape (datetime2(7)) and avoids the implicit
// narrowing that SqlClient's default DateTime → datetime applies via
// AddWithValue. Critical for partition assignment: the partition
// function key column is datetime2(7); a narrowed value would still
// land in the correct partition for first-of-month seeds, but
// explicit typing here documents the intent and matches how the
// production repository INSERT shapes its parameters.
var occurredParam = cmd.Parameters.Add("@OccurredAtUtc", System.Data.SqlDbType.DateTime2);
occurredParam.Scale = 7;
occurredParam.Value = occurredAtUtc;
var ingestedParam = cmd.Parameters.Add("@IngestedAtUtc", System.Data.SqlDbType.DateTime2);
ingestedParam.Scale = 7;
ingestedParam.Value = DateTime.UtcNow;
cmd.Parameters.Add("@SourceSiteId", System.Data.SqlDbType.VarChar, 64).Value = siteId;
await cmd.ExecuteNonQueryAsync();
}
/// <summary>
/// Asserts that <c>UX_AuditLog_EventId</c> exists in
/// <c>sys.indexes</c>. The drop-and-rebuild dance briefly removes the
/// index inside its transaction; this check is meant to fire AFTER the
/// actor's purge tick has committed so the rebuilt index is observable.
/// </summary>
private static async Task AssertUxIndexExistsAsync(SqlConnection conn)
{
await using var cmd = conn.CreateCommand();
cmd.CommandText = @"
SELECT COUNT(*)
FROM sys.indexes
WHERE name = 'UX_AuditLog_EventId'
AND object_id = OBJECT_ID('dbo.AuditLog');";
var raw = await cmd.ExecuteScalarAsync();
var count = Convert.ToInt32(raw);
Assert.True(count == 1, $"UX_AuditLog_EventId should be present post-purge; sys.indexes count was {count}.");
}
private IActorRef CreateActor(
IServiceProvider sp,
AuditLogPurgeOptions purgeOptions,
AuditLogOptions auditOptions)
{
return Sys.ActorOf(Props.Create(() => new AuditLogPurgeActor(
sp,
Options.Create(purgeOptions),
Options.Create(auditOptions),
NullLogger<AuditLogPurgeActor>.Instance)));
}
private static (DateTime Jan, DateTime Feb, DateTime Mar) SeedOccurredAt() => (
new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc),
new DateTime(2026, 2, 15, 0, 0, 0, DateTimeKind.Utc),
new DateTime(2026, 3, 15, 0, 0, 0, DateTimeKind.Utc));
// ---------------------------------------------------------------------
// 1. EndToEnd_OldestPartition_PurgedViaActor_NewerKept
// ---------------------------------------------------------------------
[SkippableFact]
public async Task EndToEnd_OldestPartition_PurgedViaActor_NewerKept()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// Test date is ~2026-05-20 per environment. We want a threshold that
// sits strictly between Jan 15 (the Jan partition's MAX) and Feb 15
// (the Feb partition's MAX) so only the Jan-2026 partition is
// eligible for purge. RetentionDays = 100 gives a threshold of
// ~2026-02-09 — Jan 15 is older (purged), Feb 15 and Mar 15 are
// newer (kept). The window between Jan 15 and Feb 15 is wide enough
// (~30 days) to tolerate any plausible test-clock drift in CI.
var siteId = "purge-e2e-" + Guid.NewGuid().ToString("N").Substring(0, 8);
var janEventId = Guid.NewGuid();
var febEventId = Guid.NewGuid();
var marEventId = Guid.NewGuid();
var (janOccurred, febOccurred, marOccurred) = SeedOccurredAt();
await using (var seedConn = _fixture.OpenConnection())
{
await DirectInsertAsync(seedConn, janEventId, janOccurred, siteId);
await DirectInsertAsync(seedConn, febEventId, febOccurred, siteId);
await DirectInsertAsync(seedConn, marEventId, marOccurred, siteId);
}
// Wire the actor with a real EF context against the fixture DB.
var services = new ServiceCollection();
services.AddDbContext<ScadaBridgeDbContext>(
opts => opts.UseSqlServer(_fixture.ConnectionString),
ServiceLifetime.Scoped);
services.AddScoped<IAuditLogRepository, AuditLogRepository>();
var sp = services.BuildServiceProvider();
var probe = CreateTestProbe();
Sys.EventStream.Subscribe(probe.Ref, typeof(AuditLogPurgedEvent));
var purgeOptions = new AuditLogPurgeOptions
{
IntervalHours = 24,
IntervalOverride = TimeSpan.FromMilliseconds(100),
};
var auditOptions = new AuditLogOptions { RetentionDays = 100 };
CreateActor(sp, purgeOptions, auditOptions);
// Wait for the actor's tick to purge the Jan-2026 partition.
// Concurrent test runs against the same fixture might also create
// eligible partitions, but each test class owns its own fixture DB
// (MsSqlMigrationFixture seeds a guid-named DB per class), so the
// Jan-2026 boundary is the only one this test can have produced.
var janBoundary = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var matched = probe.FishForMessage<AuditLogPurgedEvent>(
isMessage: m => m.MonthBoundary == janBoundary,
max: TimeSpan.FromSeconds(30));
Assert.True(matched.RowsDeleted >= 1,
$"Expected RowsDeleted >= 1 for Jan-2026 boundary; got {matched.RowsDeleted}.");
// Allow a brief settle in case the actor is mid-tick on Feb/Mar
// (it shouldn't be, since RetentionDays = 90 means only Jan is
// eligible, but the actor MAY re-enumerate quickly while we read).
await Task.Delay(TimeSpan.FromMilliseconds(500));
await using var verify = CreateContext();
var rows = await verify.Set<AuditEvent>()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
// Jan removed; Feb + Mar untouched. Because the test owns the site
// id and the fixture DB, exact set membership is observable.
Assert.DoesNotContain(rows, r => r.EventId == janEventId);
Assert.Contains(rows, r => r.EventId == febEventId);
Assert.Contains(rows, r => r.EventId == marEventId);
}
// ---------------------------------------------------------------------
// 2. EndToEnd_UxIndexRebuilt_AfterPurge
// ---------------------------------------------------------------------
[SkippableFact]
public async Task EndToEnd_UxIndexRebuilt_AfterPurge()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// Same shape as test 1 — purge the Jan-2026 partition and then
// assert the UX_AuditLog_EventId index is still present. The
// drop-and-rebuild dance briefly removes it inside its transaction
// (the SWITCH PARTITION step requires the non-aligned unique index
// to be absent), but step 5 rebuilds it before committing. Sanity-
// checking the post-COMMIT shape here documents the invariant in an
// assertable way.
var siteId = "purge-uxidx-" + Guid.NewGuid().ToString("N").Substring(0, 8);
var janEventId = Guid.NewGuid();
var (janOccurred, _, _) = SeedOccurredAt();
await using (var seedConn = _fixture.OpenConnection())
{
await DirectInsertAsync(seedConn, janEventId, janOccurred, siteId);
}
var services = new ServiceCollection();
services.AddDbContext<ScadaBridgeDbContext>(
opts => opts.UseSqlServer(_fixture.ConnectionString),
ServiceLifetime.Scoped);
services.AddScoped<IAuditLogRepository, AuditLogRepository>();
var sp = services.BuildServiceProvider();
var probe = CreateTestProbe();
Sys.EventStream.Subscribe(probe.Ref, typeof(AuditLogPurgedEvent));
CreateActor(
sp,
new AuditLogPurgeOptions
{
IntervalHours = 24,
IntervalOverride = TimeSpan.FromMilliseconds(100),
},
new AuditLogOptions { RetentionDays = 90 });
var janBoundary = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
probe.FishForMessage<AuditLogPurgedEvent>(
isMessage: m => m.MonthBoundary == janBoundary,
max: TimeSpan.FromSeconds(30));
// Open a fresh connection (the actor's pool is owned by EF) and
// assert the index is present post-purge.
await using var check = _fixture.OpenConnection();
await AssertUxIndexExistsAsync(check);
}
// ---------------------------------------------------------------------
// 3. EndToEnd_InsertIfNotExistsAsync_StillIdempotent_AfterPurge
// ---------------------------------------------------------------------
[SkippableFact]
public async Task EndToEnd_InsertIfNotExistsAsync_StillIdempotent_AfterPurge()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// Seed + purge a Jan-2026 row, THEN exercise InsertIfNotExistsAsync
// twice for a fresh (May-2026) EventId. The second call must be a
// no-op (duplicate-key collision swallowed by the repository, per
// M2 Bundle A's race-fix) — which means the rebuilt
// UX_AuditLog_EventId unique index is functioning as intended.
var siteId = "purge-idem-" + Guid.NewGuid().ToString("N").Substring(0, 8);
var janEventId = Guid.NewGuid();
var (janOccurred, _, _) = SeedOccurredAt();
await using (var seedConn = _fixture.OpenConnection())
{
await DirectInsertAsync(seedConn, janEventId, janOccurred, siteId);
}
var services = new ServiceCollection();
services.AddDbContext<ScadaBridgeDbContext>(
opts => opts.UseSqlServer(_fixture.ConnectionString),
ServiceLifetime.Scoped);
services.AddScoped<IAuditLogRepository, AuditLogRepository>();
var sp = services.BuildServiceProvider();
var probe = CreateTestProbe();
Sys.EventStream.Subscribe(probe.Ref, typeof(AuditLogPurgedEvent));
CreateActor(
sp,
new AuditLogPurgeOptions
{
IntervalHours = 24,
IntervalOverride = TimeSpan.FromMilliseconds(100),
},
new AuditLogOptions { RetentionDays = 90 });
var janBoundary = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
probe.FishForMessage<AuditLogPurgedEvent>(
isMessage: m => m.MonthBoundary == janBoundary,
max: TimeSpan.FromSeconds(30));
// Settle then exercise InsertIfNotExistsAsync twice for the same
// EventId. The repository's idempotency relies on
// UX_AuditLog_EventId being present so the IF NOT EXISTS … INSERT
// race window resolves to a duplicate-key violation the repo
// swallows. If the index were missing here, two rows would land
// and the second InsertIfNotExistsAsync would silently double-insert.
await Task.Delay(TimeSpan.FromMilliseconds(500));
var freshEventId = Guid.NewGuid();
var freshOccurred = new DateTime(2026, 5, 15, 12, 0, 0, DateTimeKind.Utc);
var freshSite = "purge-idem-fresh-" + Guid.NewGuid().ToString("N").Substring(0, 8);
var freshEvt = new AuditEvent
{
EventId = freshEventId,
OccurredAtUtc = freshOccurred,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
SourceSiteId = freshSite,
Target = "system-x/method",
};
await using (var ctx = CreateContext())
{
var repo = new AuditLogRepository(ctx);
await repo.InsertIfNotExistsAsync(freshEvt);
// Same row a second time — must be a silent no-op.
await repo.InsertIfNotExistsAsync(freshEvt);
}
await using var verify = CreateContext();
var rows = await verify.Set<AuditEvent>()
.Where(e => e.SourceSiteId == freshSite)
.ToListAsync();
Assert.Single(rows);
Assert.Equal(freshEventId, rows[0].EventId);
}
}
@@ -0,0 +1,272 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
/// <summary>
/// Bundle H — end-to-end test wiring the full Audit Log #23 M2 sync-call pipeline:
/// <see cref="FallbackAuditWriter"/> over a <see cref="SqliteAuditWriter"/> backed by
/// an in-memory SQLite database; the <see cref="SiteAuditTelemetryActor"/> drains
/// Pending rows and pushes them through a stub <see cref="ISiteStreamAuditClient"/>
/// that forwards directly to the central <see cref="AuditLogIngestActor"/> backed
/// by a real <see cref="AuditLogRepository"/> on the <see cref="MsSqlMigrationFixture"/>.
/// </summary>
/// <remarks>
/// <para>
/// This is a <b>component-level</b> integration test, not a full Akka-cluster
/// test (per the M2 brainstorm decision). The stub gRPC client short-circuits
/// the wire so we exercise the real telemetry actor, the real ingest actor, the
/// real SQLite writer, and the real MSSQL repository — without standing up a
/// Kestrel host or two-cluster topology.
/// </para>
/// <para>
/// The site-side telemetry actor's <c>Drain</c> message is private; rather than
/// expose it we drive the drain by setting <c>BusyIntervalSeconds = 1</c> so the
/// initial scheduled tick fires within a second of actor start. Tests then
/// <see cref="TestKitBase.AwaitAssertAsync"/> until the central repository
/// observes the expected rows.
/// </para>
/// <para>
/// Each test uses a unique <c>SourceSiteId</c> (Guid suffix) so concurrent tests
/// and the per-fixture MSSQL database lifetime don't interfere with each other.
/// </para>
/// </remarks>
public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public SyncCallEmissionEndToEndTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
private static string NewSiteId() =>
"test-bundle-h-" + Guid.NewGuid().ToString("N").Substring(0, 8);
private ScadaBridgeDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer(_fixture.ConnectionString)
.Options;
return new ScadaBridgeDbContext(options);
}
private static AuditEvent NewEvent(string siteId, Guid? id = null) => new()
{
EventId = id ?? Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
SourceSiteId = siteId,
Target = "external-system-a/method",
};
private static IOptions<SqliteAuditWriterOptions> InMemorySqliteOptions() =>
Options.Create(new SqliteAuditWriterOptions
{
// Per-test unique database name + Mode=Memory + Cache=Shared keeps
// the in-memory database alive for the duration of the test even
// though Microsoft.Data.Sqlite tears the file down with the last
// connection. The DatabasePath field is unused because we override
// the connection string below.
DatabasePath = "ignored",
BatchSize = 64,
ChannelCapacity = 1024,
});
private static SqliteAuditWriter CreateInMemorySqliteWriter() =>
// The 4th constructor argument is connectionStringOverride. A unique
// shared-cache in-memory URI keeps the schema scoped to this writer
// instance and torn down when the writer is disposed.
new SqliteAuditWriter(
InMemorySqliteOptions(),
NullLogger<SqliteAuditWriter>.Instance,
new FakeNodeIdentityProvider(),
connectionStringOverride: $"Data Source=file:auditlog-h-{Guid.NewGuid():N}?mode=memory&cache=shared");
private static IOptions<SiteAuditTelemetryOptions> FastTelemetryOptions() =>
Options.Create(new SiteAuditTelemetryOptions
{
BatchSize = 256,
// 1s for both intervals so the initial scheduled tick fires fast
// and any failure-driven re-tick also fires fast — without
// requiring a public Drain message to be exposed.
BusyIntervalSeconds = 1,
IdleIntervalSeconds = 1,
});
private IActorRef CreateIngestActor(IAuditLogRepository repo) =>
Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
repo,
NullLogger<AuditLogIngestActor>.Instance)));
private IActorRef CreateTelemetryActor(
ISiteAuditQueue queue,
ISiteStreamAuditClient client) =>
Sys.ActorOf(Props.Create(() => new SiteAuditTelemetryActor(
queue,
client,
FastTelemetryOptions(),
NullLogger<SiteAuditTelemetryActor>.Instance)));
[SkippableFact]
public async Task EndToEnd_OneWrittenEvent_Reaches_Central_AuditLog_Within_Reasonable_Time()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
// Real central wiring: repo + ingest actor.
await using var ingestContext = CreateContext();
var ingestRepo = new AuditLogRepository(ingestContext);
var ingestActor = CreateIngestActor(ingestRepo);
// Real site wiring: SQLite (in-memory) + ring + fallback + telemetry.
await using var sqliteWriter = CreateInMemorySqliteWriter();
var ring = new RingBufferFallback();
var fallback = new FallbackAuditWriter(
sqliteWriter,
ring,
new NoOpAuditWriteFailureCounter(),
NullLogger<FallbackAuditWriter>.Instance);
var stubClient = new DirectActorSiteStreamAuditClient(ingestActor);
CreateTelemetryActor(sqliteWriter, stubClient);
// Act — one fresh event written via the FallbackAuditWriter hot-path.
var evt = NewEvent(siteId);
await fallback.WriteAsync(evt);
// Assert — the central AuditLog row materialises within a window that
// covers initial tick (1s) + a generous slack for SQLite + the actor
// round-trip + EF/MSSQL latency.
await AwaitAssertAsync(async () =>
{
await using var readContext = CreateContext();
var readRepo = new AuditLogRepository(readContext);
var rows = await readRepo.QueryAsync(
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10));
Assert.Single(rows);
Assert.Equal(evt.EventId, rows[0].EventId);
// Central stamps IngestedAtUtc; site never sets it.
Assert.NotNull(rows[0].IngestedAtUtc);
}, TimeSpan.FromSeconds(15));
}
[SkippableFact]
public async Task EndToEnd_GrpcStubError_RowStays_Pending_NextTick_Succeeds()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var ingestContext = CreateContext();
var ingestRepo = new AuditLogRepository(ingestContext);
var ingestActor = CreateIngestActor(ingestRepo);
await using var sqliteWriter = CreateInMemorySqliteWriter();
var ring = new RingBufferFallback();
var fallback = new FallbackAuditWriter(
sqliteWriter,
ring,
new NoOpAuditWriteFailureCounter(),
NullLogger<FallbackAuditWriter>.Instance);
// Stub fails the first push; subsequent calls flow through. The
// telemetry actor's on-failure branch keeps rows in Pending state, so
// the next tick re-reads them and tries again.
var stubClient = new DirectActorSiteStreamAuditClient(ingestActor)
{
FailNextCallCount = 1,
};
CreateTelemetryActor(sqliteWriter, stubClient);
var evt = NewEvent(siteId);
await fallback.WriteAsync(evt);
// Wait long enough for at least one failure-then-success cycle. With
// both intervals = 1s the actor retries quickly; allow 15s for slow CI.
await AwaitAssertAsync(async () =>
{
await using var readContext = CreateContext();
var readRepo = new AuditLogRepository(readContext);
var rows = await readRepo.QueryAsync(
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10));
Assert.Single(rows);
Assert.Equal(evt.EventId, rows[0].EventId);
}, TimeSpan.FromSeconds(15));
Assert.True(stubClient.CallCount >= 2,
$"Expected at least one failed push + one successful push; saw {stubClient.CallCount} total client calls.");
// The site SQLite row must have flipped to Forwarded after the
// successful retry. ReadPendingAsync only returns Pending rows; the
// row should NOT show up there anymore.
var stillPending = await sqliteWriter.ReadPendingAsync(64);
Assert.DoesNotContain(stillPending, p => p.EventId == evt.EventId);
}
[SkippableFact]
public async Task EndToEnd_DuplicateSubmit_OnlyOneCentralRow()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var ingestContext = CreateContext();
var ingestRepo = new AuditLogRepository(ingestContext);
var ingestActor = CreateIngestActor(ingestRepo);
await using var sqliteWriter = CreateInMemorySqliteWriter();
var ring = new RingBufferFallback();
var fallback = new FallbackAuditWriter(
sqliteWriter,
ring,
new NoOpAuditWriteFailureCounter(),
NullLogger<FallbackAuditWriter>.Instance);
var stubClient = new DirectActorSiteStreamAuditClient(ingestActor);
CreateTelemetryActor(sqliteWriter, stubClient);
// Both writes carry the SAME EventId. Site SQLite's PRIMARY KEY
// constraint and the central repo's InsertIfNotExistsAsync both
// enforce first-write-wins, so only one central row must materialise.
var sharedId = Guid.NewGuid();
var evt1 = NewEvent(siteId, sharedId);
var evt2 = NewEvent(siteId, sharedId);
await fallback.WriteAsync(evt1);
await fallback.WriteAsync(evt2);
await AwaitAssertAsync(async () =>
{
await using var readContext = CreateContext();
var readRepo = new AuditLogRepository(readContext);
var rows = await readRepo.QueryAsync(
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10));
Assert.Single(rows);
Assert.Equal(sharedId, rows[0].EventId);
}, TimeSpan.FromSeconds(15));
}
}
@@ -0,0 +1,207 @@
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Payload;
/// <summary>
/// Bundle B (M5-T4) tests for body regex redaction in
/// <see cref="DefaultAuditPayloadFilter"/>. The body-redactor stage runs
/// regex replace against RequestSummary / ResponseSummary / ErrorDetail /
/// Extra, replacing every match with <c>&lt;redacted&gt;</c>. Regexes come
/// from <see cref="AuditLogOptions.GlobalBodyRedactors"/> plus the per-target
/// <see cref="PerTargetRedactionOverride.AdditionalBodyRedactors"/>. Each
/// regex is compiled with a 50 ms timeout so catastrophic-backtracking
/// patterns trip a <see cref="System.Text.RegularExpressions.RegexMatchTimeoutException"/>;
/// when that happens the offending field is over-redacted with
/// <c>&lt;redacted: redactor error&gt;</c> and the
/// <see cref="IAuditRedactionFailureCounter"/> is incremented. The stage runs
/// BEFORE truncation.
/// </summary>
public class BodyRegexRedactionTests
{
private static IOptionsMonitor<AuditLogOptions> Monitor(AuditLogOptions? opts = null) =>
new StaticMonitor(opts ?? new AuditLogOptions());
private static DefaultAuditPayloadFilter Filter(
AuditLogOptions? opts = null,
IAuditRedactionFailureCounter? counter = null) =>
new(Monitor(opts), NullLogger<DefaultAuditPayloadFilter>.Instance, counter);
private static AuditEvent NewEvent(
AuditStatus status = AuditStatus.Delivered,
string? request = null,
string? response = null,
string? errorDetail = null,
string? extra = null,
string? target = null) => new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = status,
Target = target,
RequestSummary = request,
ResponseSummary = response,
ErrorDetail = errorDetail,
Extra = extra,
};
[Fact]
public void GlobalRegex_HunterPassword_Redacted()
{
var opts = new AuditLogOptions
{
GlobalBodyRedactors = new List<string> { "\"password\":\\s*\"[^\"]*\"" },
};
const string input = "{\"user\":\"alice\",\"password\":\"hunter2\"}";
var evt = NewEvent(request: input);
var result = Filter(opts).Apply(evt);
Assert.NotNull(result.RequestSummary);
Assert.Contains("<redacted>", result.RequestSummary);
Assert.DoesNotContain("hunter2", result.RequestSummary);
Assert.Contains("alice", result.RequestSummary);
}
[Fact]
public void PerTargetRegex_OnlyAppliedToMatchingTarget()
{
var opts = new AuditLogOptions
{
PerTargetOverrides = new Dictionary<string, PerTargetRedactionOverride>
{
["esg.A"] = new PerTargetRedactionOverride
{
AdditionalBodyRedactors = new List<string> { "SECRET-[A-Z0-9]+" },
},
},
};
const string input = "token=SECRET-XYZ123 normal-text";
var matchedEvt = NewEvent(request: input, target: "esg.A");
var matchedResult = Filter(opts).Apply(matchedEvt);
Assert.Contains("<redacted>", matchedResult.RequestSummary!);
Assert.DoesNotContain("SECRET-XYZ123", matchedResult.RequestSummary!);
var unmatchedEvt = NewEvent(request: input, target: "esg.B");
var unmatchedResult = Filter(opts).Apply(unmatchedEvt);
Assert.Equal(input, unmatchedResult.RequestSummary);
}
[Fact]
public void RegexThrowsTimeout_FieldBecomesRedactedMarker_CounterIncrements()
{
// Catastrophic backtracking pattern: alternation with overlapping
// groups + non-matching suffix forces the engine into exponential
// work that blows past the 50 ms timeout. Append a non-'a' character
// so the suffix anchor fails and the engine has to exhaust every
// permutation.
var opts = new AuditLogOptions
{
GlobalBodyRedactors = new List<string> { "^(a+)+$" },
};
// 30 'a's followed by '!' — small enough to keep the test fast, big
// enough to overflow the 50 ms regex timeout on every machine the CI
// grid runs on.
var input = new string('a', 30) + "!";
var counter = new CountingRedactionFailureCounter();
var evt = NewEvent(request: input);
var result = Filter(opts, counter).Apply(evt);
Assert.Equal("<redacted: redactor error>", result.RequestSummary);
Assert.True(counter.Count >= 1, $"expected counter >= 1, got {counter.Count}");
}
[Fact]
public void NoRegexConfigured_FieldUnchanged()
{
var opts = new AuditLogOptions(); // no GlobalBodyRedactors, no per-target
const string input = "{\"password\":\"hunter2\"}";
var evt = NewEvent(request: input);
var result = Filter(opts).Apply(evt);
Assert.Equal(input, result.RequestSummary);
}
[Fact]
public void RedactionAppliedBeforeTruncation()
{
// A pattern that matches a long secret in the body. The full input is
// > 8 KB so truncation must run. After redaction:
// * the marker survives the cap (redaction ran first),
// * the original secret bytes do NOT survive,
// * PayloadTruncated is set.
var opts = new AuditLogOptions
{
GlobalBodyRedactors = new List<string> { "SECRET-[A-Z0-9]+" },
};
var secret = "SECRET-ABCDEF123";
var padding = new string('x', 9 * 1024);
var input = secret + padding;
Assert.True(Encoding.UTF8.GetByteCount(input) > 8192);
var evt = NewEvent(AuditStatus.Delivered, request: input);
var result = Filter(opts).Apply(evt);
Assert.NotNull(result.RequestSummary);
Assert.True(Encoding.UTF8.GetByteCount(result.RequestSummary!) <= 8192);
Assert.Contains("<redacted>", result.RequestSummary);
Assert.DoesNotContain(secret, result.RequestSummary);
Assert.True(result.PayloadTruncated);
}
[Fact]
public void CatastrophicBacktrackingRegex_AtCompileTime_RejectedAtStartup()
{
// .NET's regex engine has no compile-time detection for catastrophic
// backtracking (only structural validation), so the filter's
// protection is RUNTIME — the 50 ms per-match timeout. We assert the
// safety net behaviour: a known evil pattern compiles cleanly but
// matches time out at runtime, the field is over-redacted, and the
// failure counter is incremented. Future engines that DO support
// compile-time analysis can tighten this further; the contract here
// is that the user-facing action is never aborted.
var evilPattern = "^(a+)+$";
var opts = new AuditLogOptions
{
GlobalBodyRedactors = new List<string> { evilPattern },
};
var input = new string('a', 30) + "!";
var counter = new CountingRedactionFailureCounter();
var evt = NewEvent(request: input);
var result = Filter(opts, counter).Apply(evt);
Assert.Equal("<redacted: redactor error>", result.RequestSummary);
Assert.True(counter.Count >= 1);
}
/// <summary>Test double that counts increments.</summary>
private sealed class CountingRedactionFailureCounter : IAuditRedactionFailureCounter
{
private int _count;
public int Count => _count;
public void Increment() => System.Threading.Interlocked.Increment(ref _count);
}
/// <summary>IOptionsMonitor test double — returns the same snapshot on every read.</summary>
private sealed class StaticMonitor : IOptionsMonitor<AuditLogOptions>
{
private readonly AuditLogOptions _value;
public StaticMonitor(AuditLogOptions value) => _value = value;
public AuditLogOptions CurrentValue => _value;
public AuditLogOptions Get(string? name) => _value;
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
}
}
@@ -0,0 +1,303 @@
using System.Text;
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Payload;
/// <summary>
/// Bundle C (M5-T6) integration tests verifying that the
/// <see cref="IAuditPayloadFilter"/> wires correctly into each of the three
/// writer entry points — <see cref="FallbackAuditWriter"/> on the site hot
/// path, <see cref="CentralAuditWriter"/> on the central direct-write path,
/// and <see cref="AuditLogIngestActor"/> on the site→central telemetry ingest
/// path (both the per-row <c>IngestAuditEventsCommand</c> handler and the
/// combined <c>IngestCachedTelemetryCommand</c> dual-write handler).
/// </summary>
/// <remarks>
/// Bundle B established the filter's behaviour in isolation (truncation,
/// header redaction, body-regex redaction, SQL-parameter redaction). Bundle C
/// proves that filtering actually happens before persistence — a 10 KB
/// RequestSummary on a Delivered row must land on disk capped to 8192 bytes
/// with <c>PayloadTruncated=true</c>, regardless of whether the row was
/// written via the site's SQLite hot path, the central direct-write path, or
/// the site→central ingest pipeline. We use the production
/// <see cref="DefaultAuditPayloadFilter"/> through every test so the
/// integration is real end-to-end, not a fake-filter assertion.
/// </remarks>
public class FilterIntegrationTests
{
/// <summary>
/// Default-options filter — 8 KiB cap on success rows, 64 KiB on error
/// rows. Cached and reused; the filter is stateless w.r.t. the per-event
/// inputs and the regex cache is happy under sharing.
/// </summary>
private static IAuditPayloadFilter NewDefaultFilter()
{
var monitor = Microsoft.Extensions.Options.Options.Create(new AuditLogOptions());
return new DefaultAuditPayloadFilter(
new StaticMonitor(monitor.Value),
NullLogger<DefaultAuditPayloadFilter>.Instance);
}
private static AuditEvent NewEvent(string? request = null, Guid? eventId = null) => new()
{
EventId = eventId ?? Guid.NewGuid(),
OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
// Delivered = success cap (8 KiB). Picking a success status so the
// 10 KB payload reliably trips the filter.
Status = AuditStatus.Delivered,
RequestSummary = request,
PayloadTruncated = false,
ForwardState = AuditForwardState.Pending,
};
// -- C1.1: FallbackAuditWriter applies the filter before SQLite write ----
[Fact]
public async Task FallbackAuditWriter_AppliesFilter_BeforeSqliteWrite()
{
var dataSource =
$"file:filter-fbw-{Guid.NewGuid():N}?mode=memory&cache=shared";
// Hold the in-memory database alive for the verifier connection —
// SQLite frees a Cache=Shared in-memory DB when the last connection
// closes, so without this keep-alive the FallbackAuditWriter's
// dispose would wipe the data before we could query it.
using var keepAlive = new SqliteConnection($"Data Source={dataSource};Cache=Shared");
keepAlive.Open();
var sqliteWriter = new SqliteAuditWriter(
Microsoft.Extensions.Options.Options.Create(new SqliteAuditWriterOptions { DatabasePath = dataSource }),
NullLogger<SqliteAuditWriter>.Instance,
new FakeNodeIdentityProvider(),
connectionStringOverride: $"Data Source={dataSource};Cache=Shared");
await using var _disposeSqlite = sqliteWriter;
var fallback = new FallbackAuditWriter(
sqliteWriter,
new RingBufferFallback(),
new NoOpAuditWriteFailureCounter(),
NullLogger<FallbackAuditWriter>.Instance,
NewDefaultFilter());
var bigRequest = new string('a', 10 * 1024);
var evt = NewEvent(request: bigRequest);
await fallback.WriteAsync(evt);
// Read back via a fresh connection so we observe what actually
// landed in SQLite — not what the writer was handed.
using var verifier = new SqliteConnection($"Data Source={dataSource};Cache=Shared");
verifier.Open();
using var cmd = verifier.CreateCommand();
cmd.CommandText = "SELECT RequestSummary, PayloadTruncated FROM AuditLog WHERE EventId = $id;";
cmd.Parameters.AddWithValue("$id", evt.EventId.ToString());
using var reader = cmd.ExecuteReader();
Assert.True(reader.Read());
var persistedRequest = reader.GetString(0);
var truncatedFlag = reader.GetInt32(1);
Assert.Equal(8192, Encoding.UTF8.GetByteCount(persistedRequest));
Assert.Equal(1, truncatedFlag);
}
// -- C1.2: CentralAuditWriter applies the filter before repo insert ------
[Fact]
public async Task CentralAuditWriter_AppliesFilter_BeforeRepoInsert()
{
var repo = Substitute.For<IAuditLogRepository>();
var services = new ServiceCollection();
services.AddScoped(_ => repo);
services.AddSingleton(NewDefaultFilter());
var provider = services.BuildServiceProvider();
var writer = new CentralAuditWriter(
provider, NullLogger<CentralAuditWriter>.Instance, NewDefaultFilter());
var bigRequest = new string('b', 10 * 1024);
var evt = NewEvent(request: bigRequest);
await writer.WriteAsync(evt);
// Verify the repository saw the FILTERED event, not the raw one.
// The filter caps RequestSummary to 8192 bytes on a Delivered row
// and flags PayloadTruncated.
await repo.Received(1).InsertIfNotExistsAsync(
Arg.Is<AuditEvent>(e =>
e.EventId == evt.EventId
&& e.RequestSummary != null
&& Encoding.UTF8.GetByteCount(e.RequestSummary) == 8192
&& e.PayloadTruncated == true),
Arg.Any<CancellationToken>());
}
// -- C1.3 + C1.4: AuditLogIngestActor applies the filter on both paths ---
public class IngestActorTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public IngestActorTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
private ScadaBridgeDbContext CreateReadContext()
{
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer(_fixture.ConnectionString)
.Options;
return new ScadaBridgeDbContext(options);
}
private static string NewSiteId() =>
"test-bundle-c1-filter-" + Guid.NewGuid().ToString("N").Substring(0, 8);
/// <summary>
/// Build the IServiceProvider in the production-flavoured shape —
/// scoped repositories + a singleton <see cref="IAuditPayloadFilter"/>
/// resolved per-message from the actor's scope. Matches the
/// AddAuditLog registrations Bundle B established.
/// </summary>
private IServiceProvider BuildServiceProvider()
{
var services = new ServiceCollection();
services.AddDbContext<ScadaBridgeDbContext>(opts =>
opts.UseSqlServer(_fixture.ConnectionString)
.ConfigureWarnings(w => w.Ignore(
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
services.AddScoped<IAuditLogRepository>(sp =>
new AuditLogRepository(sp.GetRequiredService<ScadaBridgeDbContext>()));
services.AddScoped<ISiteCallAuditRepository>(sp =>
new SiteCallAuditRepository(sp.GetRequiredService<ScadaBridgeDbContext>()));
services.AddSingleton(NewDefaultFilter());
return services.BuildServiceProvider();
}
[SkippableFact]
public async Task AuditLogIngestActor_AppliesFilter_BeforeBatchInsert()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var bigRequest = new string('c', 10 * 1024);
var evt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
SourceSiteId = siteId,
RequestSummary = bigRequest,
PayloadTruncated = false,
};
var sp = BuildServiceProvider();
var actor = Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
sp, NullLogger<AuditLogIngestActor>.Instance)));
actor.Tell(new IngestAuditEventsCommand(new[] { evt }), TestActor);
ExpectMsg<IngestAuditEventsReply>(TimeSpan.FromSeconds(15));
// Verify the persisted row was filtered before INSERT.
await using var read = CreateReadContext();
var row = await read.Set<AuditEvent>()
.SingleAsync(e => e.EventId == evt.EventId);
Assert.NotNull(row.RequestSummary);
Assert.Equal(8192, Encoding.UTF8.GetByteCount(row.RequestSummary!));
Assert.True(row.PayloadTruncated);
}
[SkippableFact]
public async Task AuditLogIngestActor_CachedTelemetry_AppliesFilter()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var trackedId = TrackedOperationId.New();
var bigRequest = new string('d', 10 * 1024);
var audit = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.CachedSubmit,
Status = AuditStatus.Submitted,
SourceSiteId = siteId,
CorrelationId = trackedId.Value,
RequestSummary = bigRequest,
PayloadTruncated = false,
};
var siteCall = new SiteCall
{
TrackedOperationId = trackedId,
Channel = "ApiOutbound",
Target = "ERP.GetOrder",
SourceSite = siteId,
Status = "Submitted",
RetryCount = 0,
CreatedAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
UpdatedAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
IngestedAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
};
var sp = BuildServiceProvider();
var actor = Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
sp, NullLogger<AuditLogIngestActor>.Instance)));
actor.Tell(
new IngestCachedTelemetryCommand(new[] { new CachedTelemetryEntry(audit, siteCall) }),
TestActor);
ExpectMsg<IngestCachedTelemetryReply>(TimeSpan.FromSeconds(15));
await using var read = CreateReadContext();
var auditRow = await read.Set<AuditEvent>()
.SingleAsync(e => e.EventId == audit.EventId);
Assert.NotNull(auditRow.RequestSummary);
// Bundle C filter must run before the dual-write transaction
// commits, so the persisted AuditLog row carries the truncated
// payload.
Assert.Equal(8192, Encoding.UTF8.GetByteCount(auditRow.RequestSummary!));
Assert.True(auditRow.PayloadTruncated);
}
}
/// <summary>
/// IOptionsMonitor test double — returns the same snapshot on every read,
/// no change-token plumbing required for these tests. Mirrors the helper
/// used in <c>TruncationTests</c>.
/// </summary>
private sealed class StaticMonitor : IOptionsMonitor<AuditLogOptions>
{
private readonly AuditLogOptions _value;
public StaticMonitor(AuditLogOptions value) => _value = value;
public AuditLogOptions CurrentValue => _value;
public AuditLogOptions Get(string? name) => _value;
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
}
}
@@ -0,0 +1,217 @@
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Payload;
/// <summary>
/// Bundle B (M5-T3) tests for <see cref="DefaultAuditPayloadFilter"/> HTTP header
/// redaction. Redaction parses <see cref="AuditEvent.RequestSummary"/> /
/// <see cref="AuditEvent.ResponseSummary"/> as JSON of shape
/// <c>{"headers": {"name": "value", ...}, "body": "..."}</c>, replaces values
/// whose header NAME (case-insensitive) is in
/// <see cref="AuditLogOptions.HeaderRedactList"/> with <c>"&lt;redacted&gt;"</c>,
/// and re-serialises. Non-JSON inputs pass through unchanged (no-op for
/// emitters that have not yet adopted the convention). The stage runs BEFORE
/// truncation so the redaction marker survives the cap.
/// </summary>
public class HeaderRedactionTests
{
private static IOptionsMonitor<AuditLogOptions> Monitor(AuditLogOptions? opts = null) =>
new StaticMonitor(opts ?? new AuditLogOptions());
private static DefaultAuditPayloadFilter Filter(AuditLogOptions? opts = null) =>
new(Monitor(opts), NullLogger<DefaultAuditPayloadFilter>.Instance);
private static AuditEvent NewEvent(
AuditStatus status = AuditStatus.Delivered,
string? request = null,
string? response = null) => new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = status,
RequestSummary = request,
ResponseSummary = response,
};
private static string BuildSummary(IDictionary<string, string> headers, string body)
{
// Serialize via System.Text.Json so we get a representative shape.
return JsonSerializer.Serialize(new
{
headers = headers,
body = body,
});
}
private static IDictionary<string, JsonElement> ParseSummary(string? summary)
{
Assert.NotNull(summary);
using var doc = JsonDocument.Parse(summary!);
var dict = new Dictionary<string, JsonElement>();
foreach (var property in doc.RootElement.EnumerateObject())
{
dict[property.Name] = property.Value.Clone();
}
return dict;
}
[Fact]
public void HeaderRedaction_AuthorizationBearer_Redacted()
{
var headers = new Dictionary<string, string>
{
["Authorization"] = "Bearer secret-token-xyz",
["Content-Type"] = "application/json",
};
var input = BuildSummary(headers, "hello");
var evt = NewEvent(request: input);
var result = Filter().Apply(evt);
var parsed = ParseSummary(result.RequestSummary);
var resultHeaders = parsed["headers"];
Assert.Equal("<redacted>", resultHeaders.GetProperty("Authorization").GetString());
}
[Fact]
public void HeaderRedaction_CaseInsensitive_LowercaseAuthorization_Redacted()
{
var headers = new Dictionary<string, string>
{
["authorization"] = "Bearer secret-token-xyz",
};
var input = BuildSummary(headers, "hello");
var evt = NewEvent(request: input);
var result = Filter().Apply(evt);
var parsed = ParseSummary(result.RequestSummary);
var resultHeaders = parsed["headers"];
Assert.Equal("<redacted>", resultHeaders.GetProperty("authorization").GetString());
}
[Fact]
public void HeaderRedaction_CustomRedactList_RedactsCustomHeaderName()
{
var opts = new AuditLogOptions
{
HeaderRedactList = new List<string> { "X-Custom-Secret" },
};
var headers = new Dictionary<string, string>
{
["X-Custom-Secret"] = "topsecret",
["Authorization"] = "Bearer keep-me", // not in list anymore
};
var input = BuildSummary(headers, "hi");
var evt = NewEvent(request: input);
var result = Filter(opts).Apply(evt);
var parsed = ParseSummary(result.RequestSummary);
var resultHeaders = parsed["headers"];
Assert.Equal("<redacted>", resultHeaders.GetProperty("X-Custom-Secret").GetString());
// Authorization no longer listed -> preserved verbatim.
Assert.Equal("Bearer keep-me", resultHeaders.GetProperty("Authorization").GetString());
}
[Fact]
public void HeaderRedaction_NonJson_RequestSummary_Unchanged()
{
const string input = "this is not JSON at all";
var evt = NewEvent(request: input);
var result = Filter().Apply(evt);
Assert.Equal(input, result.RequestSummary);
}
[Fact]
public void HeaderRedaction_NoHeadersField_Unchanged()
{
var input = JsonSerializer.Serialize(new { body = "only a body, no headers" });
var evt = NewEvent(request: input);
var result = Filter().Apply(evt);
// The stage may re-serialise but the content must be semantically identical.
var parsed = ParseSummary(result.RequestSummary);
Assert.Equal("only a body, no headers", parsed["body"].GetString());
Assert.False(parsed.ContainsKey("headers"));
}
[Fact]
public void HeaderRedaction_Other_Headers_Preserved()
{
var headers = new Dictionary<string, string>
{
["Authorization"] = "Bearer secret",
["Content-Type"] = "application/json",
["X-Request-Id"] = "abc-123",
["Accept"] = "application/json",
};
var input = BuildSummary(headers, "payload");
var evt = NewEvent(request: input);
var result = Filter().Apply(evt);
var parsed = ParseSummary(result.RequestSummary);
var resultHeaders = parsed["headers"];
Assert.Equal("<redacted>", resultHeaders.GetProperty("Authorization").GetString());
Assert.Equal("application/json", resultHeaders.GetProperty("Content-Type").GetString());
Assert.Equal("abc-123", resultHeaders.GetProperty("X-Request-Id").GetString());
Assert.Equal("application/json", resultHeaders.GetProperty("Accept").GetString());
}
[Fact]
public void HeaderRedaction_AppliedBeforeTruncation()
{
// Build a summary whose Authorization header value is enormous AND whose
// body padding pushes the total beyond the 8 KB cap. After redaction the
// Authorization value becomes "<redacted>" — then truncation caps the
// re-serialised string. Result must:
// * carry "<redacted>" (header redaction ran first),
// * NOT carry the original secret bytes (proves redaction won, not order swap),
// * be capped at the configured DefaultCapBytes,
// * have PayloadTruncated == true.
const string secret = "SUPER-SECRET-TOKEN-DO-NOT-LEAK";
var headers = new Dictionary<string, string>
{
["Authorization"] = "Bearer " + secret,
};
var body = new string('x', 9 * 1024);
var input = BuildSummary(headers, body);
Assert.True(Encoding.UTF8.GetByteCount(input) > 8192);
var evt = NewEvent(AuditStatus.Delivered, request: input);
var result = Filter().Apply(evt);
Assert.NotNull(result.RequestSummary);
Assert.True(Encoding.UTF8.GetByteCount(result.RequestSummary!) <= 8192);
Assert.Contains("<redacted>", result.RequestSummary);
Assert.DoesNotContain(secret, result.RequestSummary);
Assert.True(result.PayloadTruncated);
}
/// <summary>
/// IOptionsMonitor test double — returns the same snapshot on every read,
/// no change-token plumbing required for these tests.
/// </summary>
private sealed class StaticMonitor : IOptionsMonitor<AuditLogOptions>
{
private readonly AuditLogOptions _value;
public StaticMonitor(AuditLogOptions value) => _value = value;
public AuditLogOptions CurrentValue => _value;
public AuditLogOptions Get(string? name) => _value;
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
}
}
@@ -0,0 +1,133 @@
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Payload;
/// <summary>
/// Pins the docs/plans/2026-05-23-inbound-api-full-response-audit-design.md
/// inbound carve-out: ApiInbound rows use InboundMaxBytes (default 1 MiB) for
/// RequestSummary / ResponseSummary truncation, NOT DefaultCapBytes /
/// ErrorCapBytes. Other channels keep the existing caps.
/// </summary>
/// <remarks>
/// Uses a file-local <see cref="StaticMonitor"/> helper mirroring the
/// convention in the sibling Payload tests (TruncationTests,
/// FilterIntegrationTests, BodyRegexRedactionTests, etc.) — the
/// <c>TestOptionsMonitor&lt;T&gt;</c> helper referenced by the plan is a
/// private nested class inside <c>AuditLogOptionsBindingTests</c> and thus
/// not reachable from this file.
/// </remarks>
public class InboundChannelCapTests
{
private static AuditEvent MakeInbound(
AuditStatus status,
string? request = null,
string? response = null) =>
new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiInbound,
Kind = AuditKind.InboundRequest,
Status = status,
RequestSummary = request,
ResponseSummary = response,
};
[Fact]
public void ApiInbound_Delivered_RequestBody_BelowInboundMaxBytes_NotTruncated()
{
// Body well above the legacy 8 KiB default cap but under the 1 MiB
// inbound ceiling — must NOT truncate.
var body = new string('a', 100_000);
var opts = new AuditLogOptions(); // defaults
var filter = new DefaultAuditPayloadFilter(
new StaticMonitor(opts),
NullLogger<DefaultAuditPayloadFilter>.Instance);
var result = filter.Apply(MakeInbound(AuditStatus.Delivered, request: body));
Assert.False(result.PayloadTruncated);
Assert.Equal(100_000, Encoding.UTF8.GetByteCount(result.RequestSummary!));
}
[Fact]
public void ApiInbound_Delivered_ResponseBody_BelowInboundMaxBytes_NotTruncated()
{
var body = new string('a', 100_000);
var opts = new AuditLogOptions();
var filter = new DefaultAuditPayloadFilter(
new StaticMonitor(opts),
NullLogger<DefaultAuditPayloadFilter>.Instance);
var result = filter.Apply(MakeInbound(AuditStatus.Delivered, response: body));
Assert.False(result.PayloadTruncated);
Assert.Equal(100_000, Encoding.UTF8.GetByteCount(result.ResponseSummary!));
}
[Fact]
public void ApiInbound_Failed_BodyAboveInboundMaxBytes_TruncatedToInboundMaxBytes()
{
// Even on error rows, the inbound cap is InboundMaxBytes (NOT ErrorCapBytes).
var opts = new AuditLogOptions { InboundMaxBytes = 16_384 };
var oversized = new string('z', 50_000);
var filter = new DefaultAuditPayloadFilter(
new StaticMonitor(opts),
NullLogger<DefaultAuditPayloadFilter>.Instance);
var result = filter.Apply(MakeInbound(AuditStatus.Failed, response: oversized));
Assert.True(result.PayloadTruncated);
Assert.True(Encoding.UTF8.GetByteCount(result.ResponseSummary!) <= 16_384);
}
[Fact]
public void ApiOutbound_StillUsesDefaultCap_NotInboundMaxBytes()
{
// Regression guard: lifting the inbound cap MUST NOT change other
// channels. An ApiOutbound 100 KB body still hits the 8 KiB cap.
var opts = new AuditLogOptions();
var body = new string('a', 100_000);
var filter = new DefaultAuditPayloadFilter(
new StaticMonitor(opts),
NullLogger<DefaultAuditPayloadFilter>.Instance);
var evt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
RequestSummary = body,
};
var result = filter.Apply(evt);
Assert.True(result.PayloadTruncated);
Assert.True(Encoding.UTF8.GetByteCount(result.RequestSummary!) <= opts.DefaultCapBytes);
}
/// <summary>
/// IOptionsMonitor test double — returns the same snapshot on every read,
/// no change-token plumbing required for these tests. Mirrors the helper
/// used in <c>TruncationTests</c>, <c>FilterIntegrationTests</c>, etc.
/// </summary>
private sealed class StaticMonitor : IOptionsMonitor<AuditLogOptions>
{
private readonly AuditLogOptions _value;
public StaticMonitor(AuditLogOptions value) => _value = value;
public AuditLogOptions CurrentValue => _value;
public AuditLogOptions Get(string? name) => _value;
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
}
}
@@ -0,0 +1,59 @@
using System.Linq;
using System.Reflection;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Payload;
/// <summary>
/// Bundle A (M5-T1) contract test for <see cref="IAuditPayloadFilter"/>. The
/// interface is the seam between event construction and writer persistence;
/// later bundles register the production implementation as a singleton and
/// invoke it from the site/central writer paths. We pin the surface area here
/// via reflection so accidental signature drift breaks the build before the
/// downstream wiring goes red.
/// </summary>
public class PayloadFilterContractTests
{
[Fact]
public void Interface_Exists_InPayloadNamespace()
{
var type = typeof(IAuditPayloadFilter);
Assert.True(type.IsInterface, "IAuditPayloadFilter must be an interface");
Assert.Equal("ZB.MOM.WW.ScadaBridge.AuditLog.Payload", type.Namespace);
}
[Fact]
public void Apply_Method_HasDocumentedSignature()
{
var type = typeof(IAuditPayloadFilter);
var method = type.GetMethod(
"Apply",
BindingFlags.Instance | BindingFlags.Public,
binder: null,
types: new[] { typeof(AuditEvent) },
modifiers: null);
Assert.NotNull(method);
Assert.Equal(typeof(AuditEvent), method!.ReturnType);
var parameters = method.GetParameters();
Assert.Single(parameters);
Assert.Equal("rawEvent", parameters[0].Name);
Assert.Equal(typeof(AuditEvent), parameters[0].ParameterType);
}
[Fact]
public void Interface_DeclaresExactlyOneMethod()
{
var type = typeof(IAuditPayloadFilter);
var methods = type.GetMethods(BindingFlags.Instance | BindingFlags.Public)
.Where(m => !m.IsSpecialName)
.ToArray();
Assert.Single(methods);
Assert.Equal("Apply", methods[0].Name);
}
}
@@ -0,0 +1,270 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Payload;
/// <summary>
/// Bundle D (M5-T10) safety-net edge cases for
/// <see cref="DefaultAuditPayloadFilter"/>. Bundle B already pinned the
/// happy-path safety net (catastrophic-backtracking timeout →
/// <c>&lt;redacted: redactor error&gt;</c> + counter bump); this fixture covers
/// the pathological / config-mistake corners that production operators will
/// hit when typoing a regex or shipping a half-baked redactor list.
/// </summary>
/// <remarks>
/// <para>
/// The invariants under test:
/// </para>
/// <list type="bullet">
/// <item>An UNCOMPILABLE pattern (e.g. <c>[unclosed</c>) is logged at warning
/// on first encounter and cached as invalid so it never throws again,
/// but the redactor-failure COUNTER is not bumped at bind time —
/// per the contract on <see cref="IAuditRedactionFailureCounter"/>
/// the counter tracks RUNTIME redaction failures only.</item>
/// <item>One throwing regex in the middle of a list does NOT poison the
/// other patterns — the filter stops at the failing pattern,
/// over-redacts the offending field, but lets every other field keep
/// the prior cleanly-redacted state and lets the rest of the writer
/// pipeline run.</item>
/// <item>A live config change that introduces a broken pattern does not
/// crash the filter — the bad pattern is silently dropped (logged once)
/// and the still-valid patterns continue to redact normally.</item>
/// </list>
/// </remarks>
public class RedactionSafetyNetTests
{
private static IOptionsMonitor<AuditLogOptions> Monitor(AuditLogOptions? opts = null) =>
new StaticMonitor(opts ?? new AuditLogOptions());
private static AuditEvent NewEvent(
AuditStatus status = AuditStatus.Delivered,
string? request = null,
string? response = null,
string? errorDetail = null,
string? extra = null,
string? target = null) => new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = status,
Target = target,
RequestSummary = request,
ResponseSummary = response,
ErrorDetail = errorDetail,
Extra = extra,
};
[Fact]
public void RegexNotCompilable_AtBindTime_LoggedAndSkipped()
{
// `[unclosed` is a structurally invalid character class — the .NET
// regex engine throws ArgumentException at compile time. We assert:
// * the filter does NOT throw,
// * the OTHER (valid) pattern still redacts hunter2,
// * the failure counter is NOT incremented at compile time
// (it tracks runtime redaction failures only),
// * a warning is logged exactly once.
const string badPattern = "[unclosed";
const string goodPattern = "\"password\":\\s*\"[^\"]*\"";
var opts = new AuditLogOptions
{
GlobalBodyRedactors = new List<string> { badPattern, goodPattern },
};
var counter = new CountingRedactionFailureCounter();
var spy = new SpyLogger<DefaultAuditPayloadFilter>();
var filter = new DefaultAuditPayloadFilter(Monitor(opts), spy, counter);
var evt = NewEvent(request: "{\"user\":\"alice\",\"password\":\"hunter2\"}");
var result = filter.Apply(evt);
Assert.NotNull(result.RequestSummary);
Assert.DoesNotContain("hunter2", result.RequestSummary);
Assert.Contains("<redacted>", result.RequestSummary);
Assert.Equal(0, counter.Count);
// Apply twice — the invalid-pattern compile must run AT MOST once;
// the sentinel-cache entry stops repeat compile attempts.
_ = filter.Apply(evt);
var badPatternWarnings = spy.Entries
.Where(e => e.Level == LogLevel.Warning && e.Message.Contains(badPattern))
.Count();
Assert.Equal(1, badPatternWarnings);
}
[Fact]
public void MultipleRedactors_OneThrows_OthersStillApply_ToOtherFields()
{
// Pattern set: [valid-A, evil, valid-B]. The evil pattern is
// catastrophic-backtracking on the RequestSummary input (all-'a's +
// mismatching suffix) — that field is over-redacted with the error
// marker as soon as evil throws. ResponseSummary is processed
// INDEPENDENTLY; its input does not trigger evil's backtracking, so
// valid-A and valid-B both still apply on that field. This proves a
// per-field redactor failure does not poison the rest of the writer
// call (the SQL-param stage, the truncation stage, and the other
// fields all continue normally).
const string validA = "SECRET-[A-Z0-9]+";
const string evil = "^(a+)+$"; // catastrophic on long all-'a' string
const string validB = "PIN-\\d{4}";
var opts = new AuditLogOptions
{
GlobalBodyRedactors = new List<string> { validA, evil, validB },
};
var counter = new CountingRedactionFailureCounter();
var filter = new DefaultAuditPayloadFilter(
Monitor(opts),
NullLogger<DefaultAuditPayloadFilter>.Instance,
counter);
// Request: ALL 'a's + a non-'a' suffix character. valid-A does not
// match (no SECRET-X prefix), so the buffer reaches `evil` untouched
// and triggers the backtracking explosion.
var request = new string('a', 30) + "!";
// Response: short, mismatches the evil pattern cleanly (no
// backtracking), so both valid-A and valid-B run and redact.
const string response = "SECRET-ABC456 PIN-9999 other-text";
var result = filter.Apply(NewEvent(request: request, response: response));
// RequestSummary: over-redacted (evil pattern threw).
Assert.Equal("<redacted: redactor error>", result.RequestSummary);
Assert.True(counter.Count >= 1, $"expected counter >= 1, got {counter.Count}");
// ResponseSummary: clean — both valid regexes still applied; the evil
// one ran without throwing on this short input.
Assert.NotNull(result.ResponseSummary);
Assert.DoesNotContain("SECRET-ABC456", result.ResponseSummary);
Assert.DoesNotContain("PIN-9999", result.ResponseSummary);
Assert.Contains("<redacted>", result.ResponseSummary);
Assert.Contains("other-text", result.ResponseSummary);
}
// Edge case 3 (RedactorReturnsNonStringExceptionType) intentionally
// skipped — the brief permits dropping it: there is no portable way to
// artificially trigger an OutOfMemoryException inside System.Text.RegularExpressions
// from a unit test without writing native interop, and the existing
// per-stage try/catch already covers Exception (which OOM and similar
// would derive from). Bundle B's RegexThrowsTimeout coverage exercises
// the same catch path with a deterministic trigger.
[Fact]
public void ConfigChange_WithBadRegex_LiveTrafficKeepsApplyingValidRegexes()
{
// Initial config: one valid global redactor — hunter2 is redacted.
// Reload: ADD a malformed pattern alongside the original. Per the
// safety contract, the bad pattern is logged + skipped, the original
// valid pattern keeps redacting, and the filter NEVER throws on the
// hot path. The counter must not be bumped at reload time (the
// CompiledRegex sentinel covers the bind error before runtime even
// sees it).
var monitor = new MutableMonitor(new AuditLogOptions
{
GlobalBodyRedactors = new List<string> { "\"password\":\\s*\"[^\"]*\"" },
});
var counter = new CountingRedactionFailureCounter();
var spy = new SpyLogger<DefaultAuditPayloadFilter>();
var filter = new DefaultAuditPayloadFilter(monitor, spy, counter);
var evt = NewEvent(request: "{\"user\":\"alice\",\"password\":\"hunter2\"}");
var before = filter.Apply(evt);
Assert.DoesNotContain("hunter2", before.RequestSummary!);
// Reload: malformed pattern added to the list.
monitor.Set(new AuditLogOptions
{
GlobalBodyRedactors = new List<string>
{
"\"password\":\\s*\"[^\"]*\"",
"[unclosed",
},
});
var after = filter.Apply(evt);
Assert.NotNull(after.RequestSummary);
Assert.DoesNotContain("hunter2", after.RequestSummary);
Assert.Contains("<redacted>", after.RequestSummary);
Assert.Equal(0, counter.Count);
// Compile-time warning logged for the broken pattern.
Assert.Contains(
spy.Entries,
e => e.Level == LogLevel.Warning && e.Message.Contains("[unclosed"));
}
/// <summary>Counts <see cref="IAuditRedactionFailureCounter.Increment"/> calls.</summary>
private sealed class CountingRedactionFailureCounter : IAuditRedactionFailureCounter
{
private int _count;
public int Count => _count;
public void Increment() => System.Threading.Interlocked.Increment(ref _count);
}
/// <summary>IOptionsMonitor test double — returns the same snapshot on every read.</summary>
private sealed class StaticMonitor : IOptionsMonitor<AuditLogOptions>
{
private readonly AuditLogOptions _value;
public StaticMonitor(AuditLogOptions value) => _value = value;
public AuditLogOptions CurrentValue => _value;
public AuditLogOptions Get(string? name) => _value;
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
}
/// <summary>
/// IOptionsMonitor test double that supports a live <see cref="Set"/> —
/// mirrors the helper used in
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration.AuditLogOptionsBindingTests"/>;
/// kept private here so the safety-net test file remains self-contained.
/// </summary>
private sealed class MutableMonitor : IOptionsMonitor<AuditLogOptions>
{
private AuditLogOptions _current;
public MutableMonitor(AuditLogOptions initial) => _current = initial;
public AuditLogOptions CurrentValue => _current;
public AuditLogOptions Get(string? name) => _current;
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
public void Set(AuditLogOptions value) => _current = value;
}
/// <summary>
/// Minimal ILogger that records each formatted log line so tests can
/// assert on the compile-time warning emission contract — counting
/// warnings and grepping the message text.
/// </summary>
private sealed class SpyLogger<T> : ILogger<T>
{
private readonly List<LogEntry> _entries = new();
public IReadOnlyList<LogEntry> Entries
{
get { lock (_entries) return _entries.ToArray(); }
}
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
var msg = formatter(state, exception);
lock (_entries) _entries.Add(new LogEntry(logLevel, msg));
}
private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
public void Dispose() { }
}
}
public sealed record LogEntry(LogLevel Level, string Message);
}
@@ -0,0 +1,212 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Payload;
/// <summary>
/// Bundle B (M5-T5) tests for SQL parameter redaction in
/// <see cref="DefaultAuditPayloadFilter"/>. M4 Bundle A's
/// <c>AuditingDbCommand</c> emits <c>RequestSummary</c> as
/// <c>{"sql":"...","parameters":{"@name":"value", ...}}</c>; the SQL-parameter
/// redactor parses this shape on
/// <see cref="AuditChannel.DbOutbound"/> rows, replaces values whose key
/// matches the configured case-insensitive regex with <c>&lt;redacted&gt;</c>,
/// and re-serialises. Default behaviour with no opt-in: parameter values are
/// captured verbatim. Connection lookup uses the connection-name prefix of
/// <see cref="AuditEvent.Target"/> (everything before the first <c>.</c>) so
/// the same per-connection regex applies regardless of the SQL-snippet suffix
/// that <c>AuditingDbCommand</c> appends to disambiguate rows.
/// </summary>
public class SqlParamRedactionTests
{
private static IOptionsMonitor<AuditLogOptions> Monitor(AuditLogOptions? opts = null) =>
new StaticMonitor(opts ?? new AuditLogOptions());
private static DefaultAuditPayloadFilter Filter(AuditLogOptions? opts = null) =>
new(Monitor(opts), NullLogger<DefaultAuditPayloadFilter>.Instance);
private static AuditEvent NewDbEvent(string target, string requestSummary) => new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.DbOutbound,
Kind = AuditKind.DbWrite,
Status = AuditStatus.Delivered,
Target = target,
RequestSummary = requestSummary,
};
/// <summary>
/// Build a RequestSummary in the exact shape M4's <c>AuditingDbCommand</c>
/// emits — hand-rolled JSON with <c>"sql"</c> + <c>"parameters"</c> keys.
/// Tests depend on this format; if AuditingDbCommand ever changes, this
/// helper updates in lockstep.
/// </summary>
private static string DbRequestSummary(string sql, params (string name, string value)[] parameters)
{
var sb = new System.Text.StringBuilder();
sb.Append("{\"sql\":\"").Append(sql).Append('"');
if (parameters.Length > 0)
{
sb.Append(",\"parameters\":{");
for (var i = 0; i < parameters.Length; i++)
{
if (i > 0) sb.Append(',');
sb.Append('"').Append(parameters[i].name).Append("\":\"")
.Append(parameters[i].value).Append('"');
}
sb.Append('}');
}
sb.Append('}');
return sb.ToString();
}
[Fact]
public void NoOptIn_ParamsVerbatim_Unchanged()
{
var input = DbRequestSummary(
"INSERT INTO Users (Name, Token) VALUES (@name, @token)",
("@name", "Alice"),
("@token", "secret-xyz"));
var evt = NewDbEvent("PrimaryDb.INSERT INTO Users", input);
var result = Filter().Apply(evt);
Assert.Equal(input, result.RequestSummary);
}
[Fact]
public void OptInRegex_AtToken_OrAtApikey_RedactsThoseValues_KeepsOthers()
{
var opts = new AuditLogOptions
{
PerTargetOverrides = new Dictionary<string, PerTargetRedactionOverride>
{
["PrimaryDb"] = new PerTargetRedactionOverride
{
RedactSqlParamsMatching = "^@(token|apikey)$",
},
},
};
var input = DbRequestSummary(
"INSERT INTO Users (Name, Token, ApiKey) VALUES (@name, @token, @apikey)",
("@name", "Alice"),
("@token", "secret-xyz"),
("@apikey", "k-987"));
var evt = NewDbEvent("PrimaryDb.INSERT INTO Users", input);
var result = Filter(opts).Apply(evt);
Assert.NotNull(result.RequestSummary);
Assert.Contains("\"@name\":\"Alice\"", result.RequestSummary);
Assert.Contains("\"@token\":\"<redacted>\"", result.RequestSummary);
Assert.Contains("\"@apikey\":\"<redacted>\"", result.RequestSummary);
Assert.DoesNotContain("secret-xyz", result.RequestSummary);
Assert.DoesNotContain("k-987", result.RequestSummary);
}
[Fact]
public void RegexCaseInsensitive_MatchesParamNames()
{
var opts = new AuditLogOptions
{
PerTargetOverrides = new Dictionary<string, PerTargetRedactionOverride>
{
["PrimaryDb"] = new PerTargetRedactionOverride
{
RedactSqlParamsMatching = "token",
},
},
};
var input = DbRequestSummary(
"UPDATE x SET Token = @TOKEN",
("@TOKEN", "uppercased-secret"));
var evt = NewDbEvent("PrimaryDb.UPDATE x SET Token", input);
var result = Filter(opts).Apply(evt);
Assert.NotNull(result.RequestSummary);
Assert.Contains("\"@TOKEN\":\"<redacted>\"", result.RequestSummary);
Assert.DoesNotContain("uppercased-secret", result.RequestSummary);
}
[Fact]
public void NonDbOutboundChannel_NotAffected()
{
// ApiOutbound row whose RequestSummary happens to look like the
// DbOutbound JSON shape (worst-case false positive). The SQL
// redactor must NOT touch it — channel guards the stage.
var opts = new AuditLogOptions
{
PerTargetOverrides = new Dictionary<string, PerTargetRedactionOverride>
{
["PrimaryDb"] = new PerTargetRedactionOverride
{
RedactSqlParamsMatching = "^@token$",
},
},
};
var input = DbRequestSummary(
"SELECT @token",
("@token", "should-survive"));
var evt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
Target = "PrimaryDb.SELECT", // doesn't matter — channel guards
RequestSummary = input,
};
var result = Filter(opts).Apply(evt);
Assert.Equal(input, result.RequestSummary);
}
[Fact]
public void PerTargetSetting_MatchesByTarget()
{
// Two connections — A is configured to redact tokens, B is not. Same
// payload through each must yield different results.
var opts = new AuditLogOptions
{
PerTargetOverrides = new Dictionary<string, PerTargetRedactionOverride>
{
["ConnA"] = new PerTargetRedactionOverride
{
RedactSqlParamsMatching = "^@token$",
},
},
};
var input = DbRequestSummary(
"SELECT @token",
("@token", "the-secret"));
var aEvt = NewDbEvent("ConnA.SELECT @token", input);
var bEvt = NewDbEvent("ConnB.SELECT @token", input);
var aResult = Filter(opts).Apply(aEvt);
var bResult = Filter(opts).Apply(bEvt);
Assert.Contains("<redacted>", aResult.RequestSummary!);
Assert.DoesNotContain("the-secret", aResult.RequestSummary!);
Assert.Equal(input, bResult.RequestSummary);
}
/// <summary>IOptionsMonitor test double.</summary>
private sealed class StaticMonitor : IOptionsMonitor<AuditLogOptions>
{
private readonly AuditLogOptions _value;
public StaticMonitor(AuditLogOptions value) => _value = value;
public AuditLogOptions CurrentValue => _value;
public AuditLogOptions Get(string? name) => _value;
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
}
}
@@ -0,0 +1,226 @@
using System.Linq;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Payload;
/// <summary>
/// Bundle A (M5-T2) tests for <see cref="DefaultAuditPayloadFilter"/> truncation.
/// The filter caps RequestSummary / ResponseSummary / ErrorDetail / Extra at
/// <see cref="AuditLogOptions.DefaultCapBytes"/> (8 KiB) on success rows and
/// <see cref="AuditLogOptions.ErrorCapBytes"/> (64 KiB) on error rows. "Error
/// row" = <see cref="AuditEvent.Status"/> NOT IN (<c>Delivered</c>,
/// <c>Submitted</c>, <c>Forwarded</c>). Truncation must respect UTF-8 character
/// boundaries (never split a multi-byte sequence mid-character) and must set
/// <see cref="AuditEvent.PayloadTruncated"/> true when any field is shortened.
/// </summary>
public class TruncationTests
{
private static IOptionsMonitor<AuditLogOptions> Monitor(AuditLogOptions? opts = null)
{
var snapshot = opts ?? new AuditLogOptions();
return new StaticMonitor(snapshot);
}
private static DefaultAuditPayloadFilter Filter(AuditLogOptions? opts = null) =>
new(Monitor(opts), NullLogger<DefaultAuditPayloadFilter>.Instance);
private static AuditEvent NewEvent(
AuditStatus status = AuditStatus.Delivered,
string? request = null,
string? response = null,
string? errorDetail = null,
string? extra = null,
bool payloadTruncated = false) => new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = status,
RequestSummary = request,
ResponseSummary = response,
ErrorDetail = errorDetail,
Extra = extra,
PayloadTruncated = payloadTruncated,
};
[Fact]
public void SuccessRow_10KB_RequestSummary_TruncatedTo8KB_PayloadTruncatedTrue()
{
var input = new string('a', 10 * 1024);
var evt = NewEvent(AuditStatus.Delivered, request: input);
var result = Filter().Apply(evt);
Assert.NotNull(result.RequestSummary);
Assert.Equal(8192, Encoding.UTF8.GetByteCount(result.RequestSummary!));
Assert.True(result.PayloadTruncated);
}
[Fact]
public void ErrorRow_10KB_RequestSummary_NotTruncated_UnderErrorCap()
{
var input = new string('b', 10 * 1024);
var evt = NewEvent(AuditStatus.Failed, request: input);
var result = Filter().Apply(evt);
Assert.Equal(input, result.RequestSummary);
Assert.False(result.PayloadTruncated);
}
[Fact]
public void ErrorRow_70KB_RequestSummary_TruncatedTo64KB_PayloadTruncatedTrue()
{
var input = new string('c', 70 * 1024);
var evt = NewEvent(AuditStatus.Failed, request: input);
var result = Filter().Apply(evt);
Assert.NotNull(result.RequestSummary);
Assert.Equal(65536, Encoding.UTF8.GetByteCount(result.RequestSummary!));
Assert.True(result.PayloadTruncated);
}
[Fact]
public void Multibyte_UTF8_TruncatedAtCharacterBoundary_NotMidByte()
{
// U+1F600 (grinning face) encodes to 4 UTF-8 bytes; 2000 of them = 8000 bytes,
// safely under the 8192 default cap so the boundary scan kicks in mid-character
// when we push past it. Pad with a few extra emoji so the *input* is > 8192 bytes
// and forces truncation.
var emoji = "😀"; // surrogate pair => one code point => 4 UTF-8 bytes
var sb = new StringBuilder();
for (int i = 0; i < 2100; i++)
{
sb.Append(emoji);
}
var input = sb.ToString();
Assert.True(Encoding.UTF8.GetByteCount(input) > 8192);
var evt = NewEvent(AuditStatus.Delivered, request: input);
var result = Filter().Apply(evt);
Assert.NotNull(result.RequestSummary);
var resultBytes = Encoding.UTF8.GetByteCount(result.RequestSummary!);
Assert.True(resultBytes <= 8192, $"expected <= 8192 bytes, got {resultBytes}");
// 4-byte emoji boundary: the kept byte length must be a multiple of 4.
Assert.Equal(0, resultBytes % 4);
// And round-tripping the result must not introduce a U+FFFD replacement char.
Assert.DoesNotContain('', result.RequestSummary);
Assert.True(result.PayloadTruncated);
}
[Fact]
public void NullSummary_PassesThrough_AsNull()
{
var evt = NewEvent(AuditStatus.Delivered, request: null, response: null, errorDetail: null, extra: null);
var result = Filter().Apply(evt);
Assert.Null(result.RequestSummary);
Assert.Null(result.ResponseSummary);
Assert.Null(result.ErrorDetail);
Assert.Null(result.Extra);
Assert.False(result.PayloadTruncated);
}
[Fact]
public void RawEventAlreadyTruncated_PayloadTruncatedRemainsTrue()
{
// Small payload that requires no truncation, but the caller already
// flagged PayloadTruncated upstream — the filter must not clear it.
var evt = NewEvent(AuditStatus.Delivered, request: "small", payloadTruncated: true);
var result = Filter().Apply(evt);
Assert.Equal("small", result.RequestSummary);
Assert.True(result.PayloadTruncated);
}
[Fact]
public void StatusAttempted_TreatedAsError_UsesErrorCap()
{
// 10 KB is under the 64 KB error cap; if Attempted were a success status
// the value would be truncated to 8 KB. We assert it is NOT truncated.
var input = new string('d', 10 * 1024);
var evt = NewEvent(AuditStatus.Attempted, request: input);
var result = Filter().Apply(evt);
Assert.Equal(input, result.RequestSummary);
Assert.False(result.PayloadTruncated);
}
[Fact]
public void StatusParked_TreatedAsError_UsesErrorCap()
{
var input = new string('e', 10 * 1024);
var evt = NewEvent(AuditStatus.Parked, request: input);
var result = Filter().Apply(evt);
Assert.Equal(input, result.RequestSummary);
Assert.False(result.PayloadTruncated);
}
[Fact]
public void StatusSkipped_TreatedAsError_UsesErrorCap()
{
var input = new string('f', 10 * 1024);
var evt = NewEvent(AuditStatus.Skipped, request: input);
var result = Filter().Apply(evt);
Assert.Equal(input, result.RequestSummary);
Assert.False(result.PayloadTruncated);
}
[Fact]
public void ErrorDetail_AndExtra_Truncated_Independently()
{
// Each field is capped on its own — a 10 KB RequestSummary and a 10 KB
// ErrorDetail on the same Delivered row should both be cut to 8 KB and
// the row flagged truncated.
var input = new string('g', 10 * 1024);
var evt = NewEvent(
AuditStatus.Delivered,
request: input,
response: input,
errorDetail: input,
extra: input);
var result = Filter().Apply(evt);
Assert.Equal(8192, Encoding.UTF8.GetByteCount(result.RequestSummary!));
Assert.Equal(8192, Encoding.UTF8.GetByteCount(result.ResponseSummary!));
Assert.Equal(8192, Encoding.UTF8.GetByteCount(result.ErrorDetail!));
Assert.Equal(8192, Encoding.UTF8.GetByteCount(result.Extra!));
Assert.True(result.PayloadTruncated);
}
/// <summary>
/// IOptionsMonitor test double — returns the same snapshot on every read,
/// no change-token plumbing required for these tests (Bundle D wires the
/// real hot-reload path).
/// </summary>
private sealed class StaticMonitor : IOptionsMonitor<AuditLogOptions>
{
private readonly AuditLogOptions _value;
public StaticMonitor(AuditLogOptions value) => _value = value;
public AuditLogOptions CurrentValue => _value;
public AuditLogOptions Get(string? name) => _value;
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
}
}
@@ -0,0 +1,133 @@
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// <summary>
/// Bundle B (M2-T4) tests for <see cref="FallbackAuditWriter"/> — composes the
/// primary <see cref="SqliteAuditWriter"/>, the drop-oldest
/// <see cref="RingBufferFallback"/>, and an
/// <see cref="IAuditWriteFailureCounter"/> health counter.
/// </summary>
public class FallbackAuditWriterTests
{
private static AuditEvent NewEvent(string? target = null) => new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
Target = target,
PayloadTruncated = false,
ForwardState = AuditForwardState.Pending,
};
/// <summary>Flip-switch primary writer mock.</summary>
private sealed class FlipSwitchPrimary : IAuditWriter
{
public bool FailNext { get; set; }
public List<AuditEvent> Written { get; } = new();
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
if (FailNext)
{
return Task.FromException(new InvalidOperationException("primary down"));
}
Written.Add(evt);
return Task.CompletedTask;
}
}
[Fact]
public async Task WriteAsync_PrimaryThrows_EventLandsInRing_CallReturnsSuccess()
{
var primary = new FlipSwitchPrimary { FailNext = true };
var ring = new RingBufferFallback(capacity: 16);
var counter = Substitute.For<IAuditWriteFailureCounter>();
var fallback = new FallbackAuditWriter(primary, ring, counter, NullLogger<FallbackAuditWriter>.Instance);
var evt = NewEvent("doomed");
// Must NOT throw — audit failures are always swallowed at this layer.
await fallback.WriteAsync(evt);
Assert.Equal(1, ring.Count);
counter.Received(1).Increment();
}
[Fact]
public async Task WriteAsync_PrimaryRecovers_RingDrains_InFIFOOrder_OnNextWrite()
{
var primary = new FlipSwitchPrimary { FailNext = true };
var ring = new RingBufferFallback(capacity: 16);
var counter = Substitute.For<IAuditWriteFailureCounter>();
var fallback = new FallbackAuditWriter(primary, ring, counter, NullLogger<FallbackAuditWriter>.Instance);
var failed = new[] { NewEvent("a"), NewEvent("b"), NewEvent("c") };
foreach (var e in failed)
{
await fallback.WriteAsync(e);
}
Assert.Equal(3, ring.Count);
// Primary recovers; the very next successful write should drain the
// ring in FIFO order through the primary.
primary.FailNext = false;
var trigger = NewEvent("trigger");
await fallback.WriteAsync(trigger);
Assert.Equal(0, ring.Count);
// Order: the triggering event reaches the primary first (that's the
// signal the primary has recovered), then the backlog drains in FIFO
// submission order behind it.
Assert.Equal(4, primary.Written.Count);
Assert.Equal("trigger", primary.Written[0].Target);
Assert.Equal("a", primary.Written[1].Target);
Assert.Equal("b", primary.Written[2].Target);
Assert.Equal("c", primary.Written[3].Target);
}
[Fact]
public async Task WriteAsync_PrimaryAlwaysSucceeds_Ring_StaysEmpty()
{
var primary = new FlipSwitchPrimary();
var ring = new RingBufferFallback(capacity: 16);
var counter = Substitute.For<IAuditWriteFailureCounter>();
var fallback = new FallbackAuditWriter(primary, ring, counter, NullLogger<FallbackAuditWriter>.Instance);
for (int i = 0; i < 10; i++)
{
await fallback.WriteAsync(NewEvent());
}
Assert.Equal(0, ring.Count);
Assert.Equal(10, primary.Written.Count);
counter.DidNotReceive().Increment();
}
[Fact]
public async Task WriteAsync_FailureCounter_Incremented_Per_PrimaryFailure()
{
var primary = new FlipSwitchPrimary { FailNext = true };
var ring = new RingBufferFallback(capacity: 16);
var counter = Substitute.For<IAuditWriteFailureCounter>();
var fallback = new FallbackAuditWriter(primary, ring, counter, NullLogger<FallbackAuditWriter>.Instance);
for (int i = 0; i < 5; i++)
{
await fallback.WriteAsync(NewEvent());
}
counter.Received(5).Increment();
}
}
@@ -0,0 +1,49 @@
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// <summary>
/// Bundle C (M5-T7) — the <see cref="HealthMetricsAuditRedactionFailureCounter"/>
/// adapter is the production binding for
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Payload.IAuditRedactionFailureCounter"/> on
/// site nodes; it forwards every <see cref="DefaultAuditPayloadFilter"/>
/// redactor over-redaction event into the shared
/// <see cref="ISiteHealthCollector"/> so the site health report surfaces the
/// count as <c>AuditRedactionFailure</c>. Mirrors the M2 Bundle G
/// HealthMetricsAuditWriteFailureCounter shape one-for-one.
/// </summary>
public class HealthMetricsAuditRedactionFailureCounterTests
{
[Fact]
public void Increment_Routes_To_Collector_IncrementAuditRedactionFailure()
{
var collector = Substitute.For<ISiteHealthCollector>();
var counter = new HealthMetricsAuditRedactionFailureCounter(collector);
counter.Increment();
collector.Received(1).IncrementAuditRedactionFailure();
}
[Fact]
public void Increment_Multiple_Calls_Route_To_Collector_Each_Time()
{
var collector = Substitute.For<ISiteHealthCollector>();
var counter = new HealthMetricsAuditRedactionFailureCounter(collector);
counter.Increment();
counter.Increment();
counter.Increment();
collector.Received(3).IncrementAuditRedactionFailure();
}
[Fact]
public void Construction_With_Null_Collector_Throws_ArgumentNullException()
{
Assert.Throws<ArgumentNullException>(
() => new HealthMetricsAuditRedactionFailureCounter(null!));
}
}
@@ -0,0 +1,46 @@
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// <summary>
/// Bundle G (M2-T11) — the <see cref="HealthMetricsAuditWriteFailureCounter"/>
/// adapter is the production binding for <see cref="IAuditWriteFailureCounter"/>
/// on site nodes; it forwards every FallbackAuditWriter primary failure into
/// the shared <see cref="ISiteHealthCollector"/> so the site health report
/// surfaces the failure count as <c>SiteAuditWriteFailures</c>.
/// </summary>
public class HealthMetricsAuditWriteFailureCounterTests
{
[Fact]
public void Increment_Routes_To_Collector_IncrementSiteAuditWriteFailures()
{
var collector = Substitute.For<ISiteHealthCollector>();
var counter = new HealthMetricsAuditWriteFailureCounter(collector);
counter.Increment();
collector.Received(1).IncrementSiteAuditWriteFailures();
}
[Fact]
public void Increment_Multiple_Calls_Route_To_Collector_Each_Time()
{
var collector = Substitute.For<ISiteHealthCollector>();
var counter = new HealthMetricsAuditWriteFailureCounter(collector);
counter.Increment();
counter.Increment();
counter.Increment();
collector.Received(3).IncrementSiteAuditWriteFailures();
}
[Fact]
public void Construction_With_Null_Collector_Throws_ArgumentNullException()
{
Assert.Throws<ArgumentNullException>(
() => new HealthMetricsAuditWriteFailureCounter(null!));
}
}
@@ -0,0 +1,91 @@
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// <summary>
/// Bundle B (M2-T3) tests for <see cref="RingBufferFallback"/> — the
/// drop-oldest fallback used by <see cref="FallbackAuditWriter"/> when the
/// primary SQLite writer is throwing.
/// </summary>
public class RingBufferFallbackTests
{
private static AuditEvent NewEvent(string? target = null)
{
return new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
Target = target,
PayloadTruncated = false,
ForwardState = AuditForwardState.Pending,
};
}
[Fact]
public async Task Enqueue_1025_Into_1024Cap_Ring_DropsOldest_AndRaisesOverflowOnce()
{
var ring = new RingBufferFallback(capacity: 1024);
var overflowCount = 0;
ring.RingBufferOverflowed += () => Interlocked.Increment(ref overflowCount);
var events = Enumerable.Range(0, 1025).Select(i => NewEvent(target: i.ToString())).ToList();
foreach (var e in events)
{
Assert.True(ring.TryEnqueue(e));
}
Assert.Equal(1, overflowCount);
// The surviving 1024 are events[1..1024] (oldest dropped).
var drained = new List<AuditEvent>();
ring.Complete();
await foreach (var e in ring.DrainAsync(CancellationToken.None))
{
drained.Add(e);
}
Assert.Equal(1024, drained.Count);
Assert.Equal("1", drained[0].Target);
Assert.Equal("1024", drained[^1].Target);
}
[Fact]
public async Task DrainAsync_Yields_FIFO_Then_Completes_When_Empty()
{
var ring = new RingBufferFallback(capacity: 16);
var enqueued = Enumerable.Range(0, 5).Select(i => NewEvent(target: i.ToString())).ToList();
foreach (var e in enqueued)
{
Assert.True(ring.TryEnqueue(e));
}
ring.Complete();
var drained = new List<AuditEvent>();
await foreach (var e in ring.DrainAsync(CancellationToken.None))
{
drained.Add(e);
}
Assert.Equal(5, drained.Count);
for (int i = 0; i < 5; i++)
{
Assert.Equal(i.ToString(), drained[i].Target);
}
}
[Fact]
public void TryEnqueue_AllSucceeds_ReturnsTrue()
{
var ring = new RingBufferFallback(capacity: 16);
for (int i = 0; i < 8; i++)
{
Assert.True(ring.TryEnqueue(NewEvent()));
}
}
}
@@ -0,0 +1,191 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// <summary>
/// Bundle E (M6-T6) tests for <see cref="SqliteAuditWriter.GetBacklogStatsAsync"/>.
/// Exercises the health-metric surface that <c>SiteAuditBacklogReporter</c>
/// polls every 30 s and pushes onto the site health report as
/// <c>SiteAuditBacklog</c>.
/// </summary>
public class SqliteAuditWriterBacklogStatsTests : IDisposable
{
private readonly string _dbPath;
public SqliteAuditWriterBacklogStatsTests()
{
// OnDiskBytes assertions only make sense against a real file — the
// shared-cache in-memory mode returns 0 for the file size, so this
// suite is opinionated about file-backed storage. Tests in
// SqliteAuditWriterWriteTests use in-memory for performance reasons.
_dbPath = Path.Combine(Path.GetTempPath(),
$"audit-backlog-stats-{Guid.NewGuid():N}.db");
}
public void Dispose()
{
if (File.Exists(_dbPath))
{
try { File.Delete(_dbPath); } catch { /* test cleanup best-effort */ }
}
}
private SqliteAuditWriter CreateWriter()
{
var options = new SqliteAuditWriterOptions { DatabasePath = _dbPath };
return new SqliteAuditWriter(
Options.Create(options),
NullLogger<SqliteAuditWriter>.Instance,
new FakeNodeIdentityProvider());
}
private static AuditEvent NewEvent(DateTime? occurredAtUtc = null) => new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = occurredAtUtc ?? DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
PayloadTruncated = false,
};
[Fact]
public async Task EmptyDb_Returns_Zero_Null_AndZeroBytes()
{
// No file exists yet — the writer ctor creates one but no rows are
// inserted; the snapshot should report a clean queue. OnDiskBytes is
// allowed to be zero (fresh ftruncate) OR small (page header) — the
// contract only requires non-negative; we assert >= 0 and exercise
// the pending fields strictly.
await using var writer = CreateWriter();
var snapshot = await writer.GetBacklogStatsAsync();
Assert.Equal(0, snapshot.PendingCount);
Assert.Null(snapshot.OldestPendingUtc);
Assert.True(snapshot.OnDiskBytes >= 0,
$"OnDiskBytes must be non-negative, got {snapshot.OnDiskBytes}");
}
[Fact]
public async Task Pending_5_Returns_5()
{
await using var writer = CreateWriter();
for (var i = 0; i < 5; i++)
{
await writer.WriteAsync(NewEvent());
}
var snapshot = await writer.GetBacklogStatsAsync();
Assert.Equal(5, snapshot.PendingCount);
}
[Fact]
public async Task OldestPending_Is_Earliest_OccurredAtUtc()
{
await using var writer = CreateWriter();
var t1 = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
var t2 = new DateTime(2026, 5, 20, 10, 1, 0, DateTimeKind.Utc);
var t3 = new DateTime(2026, 5, 20, 10, 2, 0, DateTimeKind.Utc);
// Insert out of order so the snapshot is not "the last write" by
// accident — the OldestPendingUtc must come from a column-min, not
// an insertion-order proxy.
await writer.WriteAsync(NewEvent(t2));
await writer.WriteAsync(NewEvent(t1));
await writer.WriteAsync(NewEvent(t3));
var snapshot = await writer.GetBacklogStatsAsync();
Assert.Equal(3, snapshot.PendingCount);
Assert.NotNull(snapshot.OldestPendingUtc);
// The DB round-trips OccurredAtUtc through the "o" format which
// preserves Kind=Utc — assert tick-equality.
Assert.Equal(t1, snapshot.OldestPendingUtc!.Value);
}
[Fact]
public async Task GetBacklogStatsAsync_DoesNotBlockOnConcurrentWriteLoad()
{
// AuditLog-005: GetBacklogStatsAsync previously took _writeLock, the
// same lock that serialises every batch INSERT in FlushBatch. Under a
// backlog growing to hundreds of thousands of rows a COUNT(*)+MIN
// index scan could park the hot-path writer for hundreds of ms. The
// fix adds a dedicated read-only connection in WAL mode so the probe
// never contends with the writer.
//
// This test demonstrates the lock decoupling by saturating the writer
// with a burst of concurrent writes and asserting that a probe issued
// while those writes are in flight returns inside a tight time bound.
// Without the fix the probe would be queued behind FlushBatch under
// the same _writeLock; with the fix it reads through _readConnection
// and is not gated by the writer.
await using var writer = CreateWriter();
// Seed a baseline so MIN(OccurredAtUtc) has a row to find — the
// important assertion is timing, but a non-empty result also confirms
// the read connection sees the writer's commits via WAL.
for (var i = 0; i < 100; i++)
{
await writer.WriteAsync(NewEvent());
}
// Kick off a sustained write burst on a background task. The writes
// are fire-and-forget — we only need the writer to be busy enough
// that any reuse of _writeLock by the probe would be observable.
var burst = Task.Run(async () =>
{
for (var i = 0; i < 2_000; i++)
{
await writer.WriteAsync(NewEvent()).ConfigureAwait(false);
}
});
// Race the probe against the write burst. The probe must return
// promptly even though the writer is actively flushing batches.
var sw = System.Diagnostics.Stopwatch.StartNew();
var snapshot = await writer.GetBacklogStatsAsync();
sw.Stop();
// Drain the burst before disposing so we don't observe a flake when
// pending writes race with dispose.
await burst;
Assert.True(sw.ElapsedMilliseconds < 1_000,
$"GetBacklogStatsAsync must not block on the writer's _writeLock; took {sw.ElapsedMilliseconds} ms");
Assert.True(snapshot.PendingCount >= 100,
$"backlog probe should see at least the seeded rows; got {snapshot.PendingCount}");
}
[Fact]
public async Task OnDiskBytes_ReturnsFileSize()
{
await using var writer = CreateWriter();
// Insert enough rows to grow the file past the empty schema baseline.
for (var i = 0; i < 100; i++)
{
await writer.WriteAsync(NewEvent());
}
var snapshot = await writer.GetBacklogStatsAsync();
// The exact size depends on SQLite page allocation, but a file-backed
// db with 100 inserted rows MUST be larger than the empty schema
// (a few pages, ~4 KB). The implementation should return the
// FileInfo.Length value verbatim.
Assert.True(File.Exists(_dbPath), $"DB file should exist at {_dbPath}");
var expected = new FileInfo(_dbPath).Length;
Assert.Equal(expected, snapshot.OnDiskBytes);
Assert.True(snapshot.OnDiskBytes > 0,
$"after 100 inserts OnDiskBytes must be > 0, got {snapshot.OnDiskBytes}");
}
}
@@ -0,0 +1,548 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// <summary>
/// Bundle B (M2-T1) schema-bootstrap tests for <see cref="SqliteAuditWriter"/>.
/// Uses an in-memory shared-cache SQLite database so the same connection name
/// reaches the same file-less db across both the writer and the verifier.
/// </summary>
public class SqliteAuditWriterSchemaTests
{
/// <summary>
/// Each test uses a unique shared-cache in-memory database. The
/// "Mode=Memory;Cache=Shared" syntax lets two SqliteConnections see the same
/// in-memory store as long as both use the same Data Source name.
/// </summary>
private static (SqliteAuditWriter writer, string dataSource) CreateWriter(string testName)
{
var dataSource = $"file:{testName}-{Guid.NewGuid():N}?mode=memory&cache=shared";
var options = new SqliteAuditWriterOptions
{
DatabasePath = dataSource,
};
// The writer uses raw "Data Source={path}" by appending Cache=Shared. Override
// by passing the full connection string via the connectionStringOverride hook.
var writer = new SqliteAuditWriter(
Options.Create(options),
NullLogger<SqliteAuditWriter>.Instance,
new FakeNodeIdentityProvider(),
connectionStringOverride: $"Data Source={dataSource};Cache=Shared");
return (writer, dataSource);
}
private static SqliteConnection OpenVerifierConnection(string dataSource)
{
var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared");
connection.Open();
return connection;
}
[Fact]
public void Opens_Creates_AuditLog_Table_With_23Columns_And_PK_On_EventId()
{
var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_AuditLog_Table_With_23Columns_And_PK_On_EventId));
using (writer)
{
using var connection = OpenVerifierConnection(dataSource);
using var cmd = connection.CreateCommand();
cmd.CommandText = "PRAGMA table_info(AuditLog);";
using var reader = cmd.ExecuteReader();
var columns = new List<(string Name, int Pk)>();
while (reader.Read())
{
columns.Add((reader.GetString(1), reader.GetInt32(5)));
}
Assert.Equal(23, columns.Count);
var expected = new[]
{
"EventId", "OccurredAtUtc", "Channel", "Kind", "CorrelationId",
"SourceSiteId", "SourceNode", "SourceInstanceId", "SourceScript", "Actor", "Target",
"Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail",
"RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra",
"ForwardState", "ExecutionId", "ParentExecutionId",
};
Assert.Equal(expected.OrderBy(n => n), columns.Select(c => c.Name).OrderBy(n => n));
// PK is EventId only.
var pkColumns = columns.Where(c => c.Pk > 0).Select(c => c.Name).ToList();
Assert.Single(pkColumns);
Assert.Equal("EventId", pkColumns[0]);
}
}
[Fact]
public void Initialize_creates_AuditLog_with_SourceNode_column()
{
var (writer, dataSource) = CreateWriter(nameof(Initialize_creates_AuditLog_with_SourceNode_column));
using (writer)
{
using var connection = OpenVerifierConnection(dataSource);
Assert.True(
ColumnExists(connection, "SourceNode"),
"Fresh AuditLog schema must include the SourceNode column.");
}
}
[Fact]
public void Opens_Creates_IX_ForwardState_Occurred_Index()
{
var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_IX_ForwardState_Occurred_Index));
using (writer)
{
using var connection = OpenVerifierConnection(dataSource);
using var cmd = connection.CreateCommand();
cmd.CommandText = "PRAGMA index_list(AuditLog);";
using var reader = cmd.ExecuteReader();
var indexNames = new List<string>();
while (reader.Read())
{
indexNames.Add(reader.GetString(1));
}
Assert.Contains("IX_SiteAuditLog_ForwardState_Occurred", indexNames);
// Verify the index columns are ForwardState, OccurredAtUtc in that order.
using var infoCmd = connection.CreateCommand();
infoCmd.CommandText = "PRAGMA index_info(IX_SiteAuditLog_ForwardState_Occurred);";
using var infoReader = infoCmd.ExecuteReader();
var indexColumns = new List<string>();
while (infoReader.Read())
{
indexColumns.Add(infoReader.GetString(2));
}
Assert.Equal(new[] { "ForwardState", "OccurredAtUtc" }, indexColumns);
}
}
[Fact]
public void PRAGMA_auto_vacuum_Is_INCREMENTAL()
{
var (writer, dataSource) = CreateWriter(nameof(PRAGMA_auto_vacuum_Is_INCREMENTAL));
using (writer)
{
using var connection = OpenVerifierConnection(dataSource);
using var cmd = connection.CreateCommand();
cmd.CommandText = "PRAGMA auto_vacuum;";
var value = Convert.ToInt32(cmd.ExecuteScalar());
// INCREMENTAL = 2 (0 = NONE, 1 = FULL, 2 = INCREMENTAL).
Assert.Equal(2, value);
}
}
// ----- ExecutionId schema-upgrade regression (persistent auditlog.db) ----- //
/// <summary>
/// The OLD pre-ExecutionId-branch <c>AuditLog</c> schema — the 20-column
/// CREATE TABLE WITHOUT the <c>ExecutionId</c> column. A real deployment's
/// on-disk <c>auditlog.db</c> already contains exactly this shape, and
/// <c>CREATE TABLE IF NOT EXISTS</c> is a no-op against it.
/// </summary>
private const string OldPreExecutionIdSchema = """
CREATE TABLE IF NOT EXISTS AuditLog (
EventId TEXT NOT NULL,
OccurredAtUtc TEXT NOT NULL,
Channel TEXT NOT NULL,
Kind TEXT NOT NULL,
CorrelationId TEXT NULL,
SourceSiteId TEXT NULL,
SourceInstanceId TEXT NULL,
SourceScript TEXT NULL,
Actor TEXT NULL,
Target TEXT NULL,
Status TEXT NOT NULL,
HttpStatus INTEGER NULL,
DurationMs INTEGER NULL,
ErrorMessage TEXT NULL,
ErrorDetail TEXT NULL,
RequestSummary TEXT NULL,
ResponseSummary TEXT NULL,
PayloadTruncated INTEGER NOT NULL,
Extra TEXT NULL,
ForwardState TEXT NOT NULL,
PRIMARY KEY (EventId)
);
CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred
ON AuditLog (ForwardState, OccurredAtUtc);
""";
/// <summary>
/// Seeds a shared-cache in-memory database with the OLD 20-column schema and
/// returns the open connection. The connection MUST stay open for the
/// lifetime of the test: a shared-cache in-memory database is dropped once
/// its last connection closes, so closing this would discard the seeded
/// schema before the writer opens its own connection.
/// </summary>
private static SqliteConnection SeedOldSchemaDatabase(string dataSource)
{
var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared");
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText = OldPreExecutionIdSchema;
cmd.ExecuteNonQuery();
return connection;
}
private static SqliteAuditWriter CreateWriterOver(string dataSource)
{
var options = new SqliteAuditWriterOptions { DatabasePath = dataSource };
return new SqliteAuditWriter(
Options.Create(options),
NullLogger<SqliteAuditWriter>.Instance,
new FakeNodeIdentityProvider(),
connectionStringOverride: $"Data Source={dataSource};Cache=Shared");
}
private static bool ColumnExists(SqliteConnection connection, string columnName)
{
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM pragma_table_info('AuditLog') WHERE name = $name";
cmd.Parameters.AddWithValue("$name", columnName);
return Convert.ToInt32(cmd.ExecuteScalar()) > 0;
}
[Fact]
public async Task Opening_Over_PreExisting_OldSchema_Db_Adds_ExecutionId_Column_And_WriteAsync_RoundTrips()
{
var dataSource = $"file:{nameof(Opening_Over_PreExisting_OldSchema_Db_Adds_ExecutionId_Column_And_WriteAsync_RoundTrips)}-{Guid.NewGuid():N}?mode=memory&cache=shared";
// A pre-branch deployment: auditlog.db already exists with the 20-column
// schema and NO ExecutionId column.
using var seedConnection = SeedOldSchemaDatabase(dataSource);
Assert.False(ColumnExists(seedConnection, "ExecutionId"));
// Upgrade: a post-branch SqliteAuditWriter opens the same database. Its
// InitializeSchema must ALTER the missing ExecutionId column in — the
// CREATE TABLE IF NOT EXISTS alone is a no-op against the existing table.
var executionId = Guid.NewGuid();
await using (var writer = CreateWriterOver(dataSource))
{
Assert.True(
ColumnExists(seedConnection, "ExecutionId"),
"SqliteAuditWriter must ALTER the ExecutionId column into a pre-existing AuditLog table.");
// A WriteAsync binding $ExecutionId must now succeed and round-trip;
// without the ALTER it would fail with "no such column: ExecutionId"
// and — because audit writes are best-effort — silently drop the row.
var evt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
PayloadTruncated = false,
ExecutionId = executionId,
};
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Equal(executionId, row.ExecutionId);
}
// Idempotency: a second writer over the now-upgraded DB must not error
// (the probe sees ExecutionId already present and skips the ALTER).
await using (var writerAgain = CreateWriterOver(dataSource))
{
Assert.True(ColumnExists(seedConnection, "ExecutionId"));
}
}
// ----- ParentExecutionId schema-upgrade regression (persistent auditlog.db) ----- //
/// <summary>
/// The pre-ParentExecutionId-branch <c>AuditLog</c> schema — the 21-column
/// CREATE TABLE that HAS <c>ExecutionId</c> but is WITHOUT
/// <c>ParentExecutionId</c>. A deployment that ran the ExecutionId branch
/// already has an on-disk <c>auditlog.db</c> in exactly this shape, and
/// <c>CREATE TABLE IF NOT EXISTS</c> is a no-op against it.
/// </summary>
private const string OldPreParentExecutionIdSchema = """
CREATE TABLE IF NOT EXISTS AuditLog (
EventId TEXT NOT NULL,
OccurredAtUtc TEXT NOT NULL,
Channel TEXT NOT NULL,
Kind TEXT NOT NULL,
CorrelationId TEXT NULL,
SourceSiteId TEXT NULL,
SourceInstanceId TEXT NULL,
SourceScript TEXT NULL,
Actor TEXT NULL,
Target TEXT NULL,
Status TEXT NOT NULL,
HttpStatus INTEGER NULL,
DurationMs INTEGER NULL,
ErrorMessage TEXT NULL,
ErrorDetail TEXT NULL,
RequestSummary TEXT NULL,
ResponseSummary TEXT NULL,
PayloadTruncated INTEGER NOT NULL,
Extra TEXT NULL,
ForwardState TEXT NOT NULL,
ExecutionId TEXT NULL,
PRIMARY KEY (EventId)
);
CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred
ON AuditLog (ForwardState, OccurredAtUtc);
""";
/// <summary>
/// Seeds a shared-cache in-memory database with the pre-ParentExecutionId
/// 21-column schema and returns the open connection. The connection MUST
/// stay open for the lifetime of the test — a shared-cache in-memory
/// database is dropped once its last connection closes.
/// </summary>
private static SqliteConnection SeedPreParentExecutionIdSchemaDatabase(string dataSource)
{
var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared");
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText = OldPreParentExecutionIdSchema;
cmd.ExecuteNonQuery();
return connection;
}
[Fact]
public async Task Opening_Over_PreExisting_PreParentExecutionId_Db_Adds_ParentExecutionId_Column_And_WriteAsync_RoundTrips()
{
var dataSource = $"file:{nameof(Opening_Over_PreExisting_PreParentExecutionId_Db_Adds_ParentExecutionId_Column_And_WriteAsync_RoundTrips)}-{Guid.NewGuid():N}?mode=memory&cache=shared";
// A deployment that ran the ExecutionId branch: auditlog.db already
// exists with the 21-column schema and NO ParentExecutionId column.
using var seedConnection = SeedPreParentExecutionIdSchemaDatabase(dataSource);
Assert.True(ColumnExists(seedConnection, "ExecutionId"));
Assert.False(ColumnExists(seedConnection, "ParentExecutionId"));
// Upgrade: a post-branch SqliteAuditWriter opens the same database. Its
// InitializeSchema must ALTER the missing ParentExecutionId column in —
// the CREATE TABLE IF NOT EXISTS alone is a no-op against the existing
// table.
var executionId = Guid.NewGuid();
var parentExecutionId = Guid.NewGuid();
await using (var writer = CreateWriterOver(dataSource))
{
Assert.True(
ColumnExists(seedConnection, "ParentExecutionId"),
"SqliteAuditWriter must ALTER the ParentExecutionId column into a pre-existing AuditLog table.");
// A WriteAsync binding $ParentExecutionId must now succeed and
// round-trip; without the ALTER it would fail with "no such column:
// ParentExecutionId" and — because audit writes are best-effort —
// silently drop the row.
var evt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
PayloadTruncated = false,
ExecutionId = executionId,
ParentExecutionId = parentExecutionId,
};
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Equal(executionId, row.ExecutionId);
Assert.Equal(parentExecutionId, row.ParentExecutionId);
}
// Idempotency: a second writer over the now-upgraded DB must not error
// (the probe sees ParentExecutionId already present and skips the ALTER).
await using (var writerAgain = CreateWriterOver(dataSource))
{
Assert.True(ColumnExists(seedConnection, "ParentExecutionId"));
}
}
[Fact]
public async Task WriteAsync_NullParentExecutionId_RoundTripsAsNull()
{
var (writer, _) = CreateWriter(nameof(WriteAsync_NullParentExecutionId_RoundTripsAsNull));
await using (writer)
{
var evt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.Notification,
Kind = AuditKind.NotifySend,
Status = AuditStatus.Submitted,
PayloadTruncated = false,
// ParentExecutionId left null
};
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Null(row.ParentExecutionId);
}
}
// ----- SourceNode schema-upgrade regression (persistent auditlog.db) ----- //
/// <summary>
/// The pre-SourceNode <c>AuditLog</c> schema — the 22-column CREATE TABLE
/// that HAS <c>ExecutionId</c> + <c>ParentExecutionId</c> but is WITHOUT
/// <c>SourceNode</c>. A deployment that ran the ParentExecutionId branch
/// already has an on-disk <c>auditlog.db</c> in exactly this shape, and
/// <c>CREATE TABLE IF NOT EXISTS</c> is a no-op against it.
/// </summary>
private const string OldPreSourceNodeSchema = """
CREATE TABLE IF NOT EXISTS AuditLog (
EventId TEXT NOT NULL,
OccurredAtUtc TEXT NOT NULL,
Channel TEXT NOT NULL,
Kind TEXT NOT NULL,
CorrelationId TEXT NULL,
SourceSiteId TEXT NULL,
SourceInstanceId TEXT NULL,
SourceScript TEXT NULL,
Actor TEXT NULL,
Target TEXT NULL,
Status TEXT NOT NULL,
HttpStatus INTEGER NULL,
DurationMs INTEGER NULL,
ErrorMessage TEXT NULL,
ErrorDetail TEXT NULL,
RequestSummary TEXT NULL,
ResponseSummary TEXT NULL,
PayloadTruncated INTEGER NOT NULL,
Extra TEXT NULL,
ForwardState TEXT NOT NULL,
ExecutionId TEXT NULL,
ParentExecutionId TEXT NULL,
PRIMARY KEY (EventId)
);
CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred
ON AuditLog (ForwardState, OccurredAtUtc);
""";
/// <summary>
/// Seeds a shared-cache in-memory database with the pre-SourceNode 22-column
/// schema and returns the open connection. The connection MUST stay open for
/// the lifetime of the test — a shared-cache in-memory database is dropped
/// once its last connection closes.
/// </summary>
private static SqliteConnection SeedPreSourceNodeSchemaDatabase(string dataSource)
{
var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared");
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText = OldPreSourceNodeSchema;
cmd.ExecuteNonQuery();
return connection;
}
[Fact]
public async Task Initialize_adds_SourceNode_to_pre_existing_schema()
{
var dataSource = $"file:{nameof(Initialize_adds_SourceNode_to_pre_existing_schema)}-{Guid.NewGuid():N}?mode=memory&cache=shared";
// A deployment that ran the ParentExecutionId branch: auditlog.db
// already exists with the 22-column schema and NO SourceNode column.
using var seedConnection = SeedPreSourceNodeSchemaDatabase(dataSource);
Assert.True(ColumnExists(seedConnection, "ExecutionId"));
Assert.True(ColumnExists(seedConnection, "ParentExecutionId"));
Assert.False(ColumnExists(seedConnection, "SourceNode"));
// Upgrade: a post-branch SqliteAuditWriter opens the same database. Its
// InitializeSchema must ALTER the missing SourceNode column in — the
// CREATE TABLE IF NOT EXISTS alone is a no-op against the existing table.
await using (var writer = CreateWriterOver(dataSource))
{
Assert.True(
ColumnExists(seedConnection, "SourceNode"),
"SqliteAuditWriter must ALTER the SourceNode column into a pre-existing AuditLog table.");
// A WriteAsync binding $SourceNode must now succeed and round-trip;
// without the ALTER it would fail with "no such column: SourceNode"
// and — because audit writes are best-effort — silently drop the row.
var evt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
PayloadTruncated = false,
SourceNode = "node-a",
};
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Equal("node-a", row.SourceNode);
}
// Idempotency: a second writer over the now-upgraded DB must not error
// (the probe sees SourceNode already present and skips the ALTER).
await using (var writerAgain = CreateWriterOver(dataSource))
{
Assert.True(ColumnExists(seedConnection, "SourceNode"));
}
}
[Fact]
public async Task WriteAsync_persists_SourceNode_field()
{
var (writer, _) = CreateWriter(nameof(WriteAsync_persists_SourceNode_field));
await using (writer)
{
var evt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
PayloadTruncated = false,
SourceNode = "node-a",
};
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Equal("node-a", row.SourceNode);
}
}
[Fact]
public async Task WriteAsync_persists_null_SourceNode()
{
var (writer, _) = CreateWriter(nameof(WriteAsync_persists_null_SourceNode));
await using (writer)
{
var evt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.Notification,
Kind = AuditKind.NotifySend,
Status = AuditStatus.Submitted,
PayloadTruncated = false,
// SourceNode left null
};
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Null(row.SourceNode);
}
}
}
@@ -0,0 +1,451 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// <summary>
/// Bundle B (M2-T2) hot-path tests for <see cref="SqliteAuditWriter"/>. Exercise
/// the Channel-based enqueue, the background writer's batch INSERTs, duplicate-
/// EventId swallowing, ForwardState defaulting, and the
/// <see cref="SqliteAuditWriter.ReadPendingAsync"/> /
/// <see cref="SqliteAuditWriter.MarkForwardedAsync"/> support surface that
/// Bundle D's telemetry actor will call.
/// </summary>
public class SqliteAuditWriterWriteTests
{
private static (SqliteAuditWriter writer, string dataSource) CreateWriter(
string testName,
int? channelCapacity = null,
INodeIdentityProvider? nodeIdentity = null)
{
var dataSource = $"file:{testName}-{Guid.NewGuid():N}?mode=memory&cache=shared";
var opts = new SqliteAuditWriterOptions { DatabasePath = dataSource };
if (channelCapacity is int cap)
{
opts.ChannelCapacity = cap;
}
// Default identity provider returns null — existing tests pre-date
// SourceNode stamping and have no expectation about it. New stamping
// tests pass a real provider via the parameter.
var identity = nodeIdentity ?? new FakeNodeIdentityProvider();
var writer = new SqliteAuditWriter(
Options.Create(opts),
NullLogger<SqliteAuditWriter>.Instance,
identity,
connectionStringOverride: $"Data Source={dataSource};Cache=Shared");
return (writer, dataSource);
}
private static SqliteConnection OpenVerifierConnection(string dataSource)
{
var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared");
connection.Open();
return connection;
}
private static AuditEvent NewEvent(Guid? id = null, DateTime? occurredAtUtc = null)
{
return new AuditEvent
{
EventId = id ?? Guid.NewGuid(),
OccurredAtUtc = occurredAtUtc ?? DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
PayloadTruncated = false,
};
}
[Fact]
public async Task WriteAsync_FreshEvent_PersistsWithForwardStatePending()
{
var (writer, dataSource) = CreateWriter(nameof(WriteAsync_FreshEvent_PersistsWithForwardStatePending));
await using var _ = writer;
var evt = NewEvent();
await writer.WriteAsync(evt);
using var connection = OpenVerifierConnection(dataSource);
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT ForwardState FROM AuditLog WHERE EventId = $id;";
cmd.Parameters.AddWithValue("$id", evt.EventId.ToString());
var actual = cmd.ExecuteScalar() as string;
Assert.Equal(AuditForwardState.Pending.ToString(), actual);
}
[Fact]
public async Task WriteAsync_Concurrent_1000Calls_All_Persist_NoExceptions()
{
var (writer, dataSource) = CreateWriter(nameof(WriteAsync_Concurrent_1000Calls_All_Persist_NoExceptions));
await using var _ = writer;
var events = Enumerable.Range(0, 1000).Select(_ => NewEvent()).ToList();
await Parallel.ForEachAsync(events, new ParallelOptions { MaxDegreeOfParallelism = 16 },
async (evt, ct) => await writer.WriteAsync(evt, ct));
using var connection = OpenVerifierConnection(dataSource);
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM AuditLog;";
var count = Convert.ToInt64(cmd.ExecuteScalar());
Assert.Equal(1000, count);
}
[Fact]
public async Task WriteAsync_DuplicateEventId_FirstWriteWins_NoException()
{
var (writer, dataSource) = CreateWriter(nameof(WriteAsync_DuplicateEventId_FirstWriteWins_NoException));
await using var _ = writer;
var sharedId = Guid.NewGuid();
var first = NewEvent(sharedId) with { Target = "first" };
var second = NewEvent(sharedId) with { Target = "second" };
await writer.WriteAsync(first);
await writer.WriteAsync(second);
using var connection = OpenVerifierConnection(dataSource);
using var countCmd = connection.CreateCommand();
countCmd.CommandText = "SELECT COUNT(*) FROM AuditLog WHERE EventId = $id;";
countCmd.Parameters.AddWithValue("$id", sharedId.ToString());
var count = Convert.ToInt64(countCmd.ExecuteScalar());
Assert.Equal(1, count);
using var targetCmd = connection.CreateCommand();
targetCmd.CommandText = "SELECT Target FROM AuditLog WHERE EventId = $id;";
targetCmd.Parameters.AddWithValue("$id", sharedId.ToString());
Assert.Equal("first", targetCmd.ExecuteScalar() as string);
}
[Fact]
public async Task WriteAsync_ForcesForwardStatePending_IfNull()
{
var (writer, dataSource) = CreateWriter(nameof(WriteAsync_ForcesForwardStatePending_IfNull));
await using var _ = writer;
var evt = NewEvent() with { ForwardState = null };
await writer.WriteAsync(evt);
using var connection = OpenVerifierConnection(dataSource);
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT ForwardState FROM AuditLog WHERE EventId = $id;";
cmd.Parameters.AddWithValue("$id", evt.EventId.ToString());
Assert.Equal(AuditForwardState.Pending.ToString(), cmd.ExecuteScalar() as string);
}
[Fact]
public async Task ReadPendingAsync_Returns_OldestFirst_LimitedToN()
{
var (writer, _) = CreateWriter(nameof(ReadPendingAsync_Returns_OldestFirst_LimitedToN));
await using var _writer = writer;
var baseTime = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc);
var evts = new[]
{
NewEvent(occurredAtUtc: baseTime.AddSeconds(5)),
NewEvent(occurredAtUtc: baseTime.AddSeconds(1)),
NewEvent(occurredAtUtc: baseTime.AddSeconds(3)),
NewEvent(occurredAtUtc: baseTime.AddSeconds(2)),
NewEvent(occurredAtUtc: baseTime.AddSeconds(4)),
};
foreach (var e in evts)
{
await writer.WriteAsync(e);
}
var rows = await writer.ReadPendingAsync(limit: 3);
Assert.Equal(3, rows.Count);
Assert.Equal(baseTime.AddSeconds(1), rows[0].OccurredAtUtc);
Assert.Equal(baseTime.AddSeconds(2), rows[1].OccurredAtUtc);
Assert.Equal(baseTime.AddSeconds(3), rows[2].OccurredAtUtc);
}
[Fact]
public async Task MarkForwardedAsync_FlipsRowsToForwarded()
{
var (writer, dataSource) = CreateWriter(nameof(MarkForwardedAsync_FlipsRowsToForwarded));
await using var _ = writer;
var ids = new[] { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() };
foreach (var id in ids)
{
await writer.WriteAsync(NewEvent(id));
}
await writer.MarkForwardedAsync(ids);
using var connection = OpenVerifierConnection(dataSource);
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT ForwardState, COUNT(*) FROM AuditLog GROUP BY ForwardState;";
using var reader = cmd.ExecuteReader();
var byState = new Dictionary<string, long>();
while (reader.Read())
{
byState[reader.GetString(0)] = reader.GetInt64(1);
}
Assert.Equal(3, byState[AuditForwardState.Forwarded.ToString()]);
Assert.False(byState.ContainsKey(AuditForwardState.Pending.ToString()));
}
[Fact]
public async Task MarkForwardedAsync_NonExistentId_NoThrow()
{
var (writer, _) = CreateWriter(nameof(MarkForwardedAsync_NonExistentId_NoThrow));
await using var _writer = writer;
var phantomIds = new[] { Guid.NewGuid(), Guid.NewGuid() };
await writer.MarkForwardedAsync(phantomIds);
// No assertion needed: the call must complete without throwing.
}
// ----- M6 reconciliation pull surface ----- //
[Fact]
public async Task ReadPendingSinceAsync_Returns_PendingAndForwarded_OldestFirst_LimitedToN()
{
var (writer, dataSource) = CreateWriter(nameof(ReadPendingSinceAsync_Returns_PendingAndForwarded_OldestFirst_LimitedToN));
await using var _ = writer;
var baseTime = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc);
var evts = new[]
{
NewEvent(occurredAtUtc: baseTime.AddSeconds(5)),
NewEvent(occurredAtUtc: baseTime.AddSeconds(1)),
NewEvent(occurredAtUtc: baseTime.AddSeconds(3)),
NewEvent(occurredAtUtc: baseTime.AddSeconds(2)),
NewEvent(occurredAtUtc: baseTime.AddSeconds(4)),
};
foreach (var e in evts) await writer.WriteAsync(e);
// Flip half to Forwarded — they must still surface in the reconciliation pull
// because central hasn't confirmed they were ingested yet.
await writer.MarkForwardedAsync(new[] { evts[0].EventId, evts[2].EventId });
var rows = await writer.ReadPendingSinceAsync(sinceUtc: DateTime.MinValue, batchSize: 3);
Assert.Equal(3, rows.Count);
Assert.Equal(baseTime.AddSeconds(1), rows[0].OccurredAtUtc);
Assert.Equal(baseTime.AddSeconds(2), rows[1].OccurredAtUtc);
Assert.Equal(baseTime.AddSeconds(3), rows[2].OccurredAtUtc);
}
[Fact]
public async Task ReadPendingSinceAsync_ExcludesRowsOlderThanSinceUtc()
{
var (writer, _) = CreateWriter(nameof(ReadPendingSinceAsync_ExcludesRowsOlderThanSinceUtc));
await using var _w = writer;
var baseTime = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc);
var old = NewEvent(occurredAtUtc: baseTime.AddSeconds(-30));
var newer1 = NewEvent(occurredAtUtc: baseTime.AddSeconds(10));
var newer2 = NewEvent(occurredAtUtc: baseTime.AddSeconds(20));
await writer.WriteAsync(old);
await writer.WriteAsync(newer1);
await writer.WriteAsync(newer2);
var rows = await writer.ReadPendingSinceAsync(sinceUtc: baseTime, batchSize: 10);
Assert.Equal(2, rows.Count);
Assert.Contains(rows, r => r.EventId == newer1.EventId);
Assert.Contains(rows, r => r.EventId == newer2.EventId);
Assert.DoesNotContain(rows, r => r.EventId == old.EventId);
}
[Fact]
public async Task ReadPendingSinceAsync_ExcludesReconciledRows()
{
var (writer, _) = CreateWriter(nameof(ReadPendingSinceAsync_ExcludesReconciledRows));
await using var _w = writer;
var baseTime = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc);
var pending = NewEvent(occurredAtUtc: baseTime);
var reconciled = NewEvent(occurredAtUtc: baseTime.AddSeconds(1));
await writer.WriteAsync(pending);
await writer.WriteAsync(reconciled);
await writer.MarkReconciledAsync(new[] { reconciled.EventId });
var rows = await writer.ReadPendingSinceAsync(sinceUtc: DateTime.MinValue, batchSize: 10);
Assert.Single(rows);
Assert.Equal(pending.EventId, rows[0].EventId);
}
[Fact]
public async Task ReadPendingSinceAsync_InvalidBatchSize_Throws()
{
var (writer, _) = CreateWriter(nameof(ReadPendingSinceAsync_InvalidBatchSize_Throws));
await using var _w = writer;
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(
() => writer.ReadPendingSinceAsync(DateTime.MinValue, batchSize: 0));
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(
() => writer.ReadPendingSinceAsync(DateTime.MinValue, batchSize: -3));
}
[Fact]
public async Task MarkReconciledAsync_FlipsPendingAndForwarded_To_Reconciled()
{
var (writer, dataSource) = CreateWriter(nameof(MarkReconciledAsync_FlipsPendingAndForwarded_To_Reconciled));
await using var _ = writer;
var a = NewEvent();
var b = NewEvent();
var c = NewEvent();
await writer.WriteAsync(a);
await writer.WriteAsync(b);
await writer.WriteAsync(c);
// b is currently Forwarded; a and c are Pending.
await writer.MarkForwardedAsync(new[] { b.EventId });
await writer.MarkReconciledAsync(new[] { a.EventId, b.EventId, c.EventId });
using var connection = OpenVerifierConnection(dataSource);
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT ForwardState, COUNT(*) FROM AuditLog GROUP BY ForwardState;";
using var reader = cmd.ExecuteReader();
var byState = new Dictionary<string, long>();
while (reader.Read())
{
byState[reader.GetString(0)] = reader.GetInt64(1);
}
Assert.Equal(3, byState[AuditForwardState.Reconciled.ToString()]);
Assert.False(byState.ContainsKey(AuditForwardState.Pending.ToString()));
Assert.False(byState.ContainsKey(AuditForwardState.Forwarded.ToString()));
}
[Fact]
public async Task MarkReconciledAsync_Idempotent_LeavesAlreadyReconciledRowsUntouched()
{
var (writer, dataSource) = CreateWriter(nameof(MarkReconciledAsync_Idempotent_LeavesAlreadyReconciledRowsUntouched));
await using var _ = writer;
var a = NewEvent();
await writer.WriteAsync(a);
await writer.MarkReconciledAsync(new[] { a.EventId });
// Re-call must not throw and must leave the single row Reconciled.
await writer.MarkReconciledAsync(new[] { a.EventId });
using var connection = OpenVerifierConnection(dataSource);
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT ForwardState FROM AuditLog WHERE EventId = $id;";
cmd.Parameters.AddWithValue("$id", a.EventId.ToString());
Assert.Equal(AuditForwardState.Reconciled.ToString(), cmd.ExecuteScalar() as string);
}
[Fact]
public async Task MarkReconciledAsync_NonExistentId_NoThrow()
{
var (writer, _) = CreateWriter(nameof(MarkReconciledAsync_NonExistentId_NoThrow));
await using var _w = writer;
await writer.MarkReconciledAsync(new[] { Guid.NewGuid(), Guid.NewGuid() });
// Completes without throwing.
}
// ----- ExecutionId column (universal per-run correlation value) ----- //
[Fact]
public async Task WriteAsync_NonNullExecutionId_RoundTripsThroughMapRow()
{
var (writer, _) = CreateWriter(nameof(WriteAsync_NonNullExecutionId_RoundTripsThroughMapRow));
await using var _w = writer;
var executionId = Guid.NewGuid();
var evt = NewEvent() with { ExecutionId = executionId };
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Equal(executionId, row.ExecutionId);
}
[Fact]
public async Task WriteAsync_NullExecutionId_RoundTripsAsNull()
{
var (writer, _) = CreateWriter(nameof(WriteAsync_NullExecutionId_RoundTripsAsNull));
await using var _w = writer;
var evt = NewEvent() with { ExecutionId = null };
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Null(row.ExecutionId);
}
// ----- SourceNode stamping (Tasks 11/12) ----- //
[Fact]
public async Task WriteAsync_StampsSourceNodeFromProvider_WhenEventHasNone()
{
var (writer, _) = CreateWriter(
nameof(WriteAsync_StampsSourceNodeFromProvider_WhenEventHasNone),
nodeIdentity: new FakeNodeIdentityProvider("node-a"));
await using var _w = writer;
var evt = NewEvent();
Assert.Null(evt.SourceNode); // sanity check — fresh event has no SourceNode
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Equal("node-a", row.SourceNode);
}
[Fact]
public async Task WriteAsync_PreservesCallerProvidedSourceNode()
{
var (writer, _) = CreateWriter(
nameof(WriteAsync_PreservesCallerProvidedSourceNode),
nodeIdentity: new FakeNodeIdentityProvider("node-a"));
await using var _w = writer;
// Reconciled rows from another node arrive with their origin's
// SourceNode already populated; the writer must preserve it.
var evt = NewEvent() with { SourceNode = "node-z" };
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Equal("node-z", row.SourceNode);
}
[Fact]
public async Task WriteAsync_LeavesSourceNodeNull_WhenProviderReturnsNull()
{
var (writer, _) = CreateWriter(
nameof(WriteAsync_LeavesSourceNodeNull_WhenProviderReturnsNull),
nodeIdentity: new FakeNodeIdentityProvider(nodeName: null));
await using var _w = writer;
var evt = NewEvent();
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Null(row.SourceNode);
}
}
@@ -0,0 +1,394 @@
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site.Telemetry;
/// <summary>
/// Bundle E Tasks E4/E5 bridge tests. The bridge ingests
/// <see cref="CachedCallAttemptContext"/> notifications from the S&amp;F
/// retry loop and routes them through <see cref="ICachedCallTelemetryForwarder"/>
/// as one or two <see cref="CachedCallTelemetry"/> packets:
/// <list type="bullet">
/// <item><description>Per-attempt: one <c>ApiCallCached</c>/<c>DbWriteCached</c> Attempted row.</description></item>
/// <item><description>Terminal (Delivered/PermanentFailure/ParkedMaxRetries): adds a CachedResolve row carrying the terminal Status.</description></item>
/// </list>
/// </summary>
public class CachedCallLifecycleBridgeTests
{
private readonly ICachedCallTelemetryForwarder _forwarder = Substitute.For<ICachedCallTelemetryForwarder>();
private readonly TrackedOperationId _id = TrackedOperationId.New();
private CachedCallLifecycleBridge CreateSut() => new(
_forwarder, NullLogger<CachedCallLifecycleBridge>.Instance);
private CachedCallAttemptContext Ctx(
CachedCallAttemptOutcome outcome,
string channel = "ApiOutbound",
int retryCount = 1,
string? lastError = null,
int? httpStatus = null,
Guid? executionId = null,
string? sourceScript = null,
Guid? parentExecutionId = null) =>
new(
TrackedOperationId: _id,
Channel: channel,
Target: "ERP.GetOrder",
SourceSite: "site-77",
Outcome: outcome,
RetryCount: retryCount,
LastError: lastError,
HttpStatus: httpStatus,
CreatedAtUtc: new DateTime(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc),
OccurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
DurationMs: 42,
SourceInstanceId: "Plant.Pump42",
ExecutionId: executionId,
SourceScript: sourceScript,
ParentExecutionId: parentExecutionId);
[Fact]
public async Task TransientFailure_EmitsOneAttemptedRow_NoResolve()
{
var captured = new List<CachedCallTelemetry>();
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured.Add(t)), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var sut = CreateSut();
await sut.OnAttemptCompletedAsync(Ctx(
CachedCallAttemptOutcome.TransientFailure,
retryCount: 2,
lastError: "HTTP 503",
httpStatus: 503));
var packet = Assert.Single(captured);
Assert.Equal(AuditKind.ApiCallCached, packet.Audit.Kind);
Assert.Equal(AuditStatus.Attempted, packet.Audit.Status);
Assert.Equal(503, packet.Audit.HttpStatus);
Assert.Equal("HTTP 503", packet.Audit.ErrorMessage);
Assert.Equal(_id.Value, packet.Audit.CorrelationId);
Assert.Equal("Attempted", packet.Operational.Status);
Assert.Equal(2, packet.Operational.RetryCount);
Assert.Null(packet.Operational.TerminalAtUtc);
}
[Fact]
public async Task Delivered_EmitsAttemptedRow_AndCachedResolveDelivered()
{
var captured = new List<CachedCallTelemetry>();
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured.Add(t)), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var sut = CreateSut();
await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.Delivered));
Assert.Equal(2, captured.Count);
var attempted = captured[0];
Assert.Equal(AuditKind.ApiCallCached, attempted.Audit.Kind);
Assert.Equal(AuditStatus.Attempted, attempted.Audit.Status);
Assert.Equal("Attempted", attempted.Operational.Status);
Assert.Null(attempted.Operational.TerminalAtUtc);
var resolve = captured[1];
Assert.Equal(AuditKind.CachedResolve, resolve.Audit.Kind);
Assert.Equal(AuditStatus.Delivered, resolve.Audit.Status);
Assert.Equal("Delivered", resolve.Operational.Status);
Assert.NotNull(resolve.Operational.TerminalAtUtc);
Assert.Equal(_id.Value, resolve.Audit.CorrelationId);
}
[Fact]
public async Task PermanentFailure_EmitsAttempted_AndCachedResolveParked()
{
var captured = new List<CachedCallTelemetry>();
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured.Add(t)), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var sut = CreateSut();
await sut.OnAttemptCompletedAsync(Ctx(
CachedCallAttemptOutcome.PermanentFailure,
lastError: "Permanent failure (handler returned false)"));
Assert.Equal(2, captured.Count);
Assert.Equal(AuditKind.ApiCallCached, captured[0].Audit.Kind);
Assert.Equal(AuditKind.CachedResolve, captured[1].Audit.Kind);
Assert.Equal(AuditStatus.Parked, captured[1].Audit.Status);
Assert.Equal("Parked", captured[1].Operational.Status);
}
[Fact]
public async Task ParkedMaxRetries_EmitsAttempted_AndCachedResolveParked()
{
var captured = new List<CachedCallTelemetry>();
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured.Add(t)), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var sut = CreateSut();
await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.ParkedMaxRetries));
Assert.Equal(2, captured.Count);
Assert.Equal(AuditKind.CachedResolve, captured[1].Audit.Kind);
Assert.Equal(AuditStatus.Parked, captured[1].Audit.Status);
}
[Fact]
public async Task DbChannel_MapsToDbWriteCachedKind_AndDbOutboundChannel()
{
var captured = new List<CachedCallTelemetry>();
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured.Add(t)), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var sut = CreateSut();
await sut.OnAttemptCompletedAsync(Ctx(
CachedCallAttemptOutcome.Delivered, channel: "DbOutbound"));
Assert.Equal(2, captured.Count);
Assert.Equal(AuditKind.DbWriteCached, captured[0].Audit.Kind);
Assert.Equal(AuditChannel.DbOutbound, captured[0].Audit.Channel);
Assert.Equal("DbOutbound", captured[0].Operational.Channel);
Assert.Equal(AuditKind.CachedResolve, captured[1].Audit.Kind);
Assert.Equal(AuditChannel.DbOutbound, captured[1].Audit.Channel);
}
[Fact]
public async Task BridgeDoesNotThrow_WhenForwarderThrows()
{
_forwarder
.ForwardAsync(Arg.Any<CachedCallTelemetry>(), Arg.Any<CancellationToken>())
.Returns(Task.FromException(new InvalidOperationException("forwarder down")));
var sut = CreateSut();
// Must not throw — best-effort emission.
await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.Delivered));
}
[Fact]
public async Task BridgePopulatesProvenance_FromAttemptContext()
{
CachedCallTelemetry? captured = null;
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured = t), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var sut = CreateSut();
await sut.OnAttemptCompletedAsync(Ctx(
CachedCallAttemptOutcome.TransientFailure,
retryCount: 3,
lastError: "transient",
httpStatus: 500));
Assert.NotNull(captured);
Assert.Equal("site-77", captured!.Audit.SourceSiteId);
Assert.Equal("Plant.Pump42", captured.Audit.SourceInstanceId);
Assert.Equal("ERP.GetOrder", captured.Audit.Target);
Assert.Equal(42, captured.Audit.DurationMs);
Assert.Equal(_id.Value, captured.Audit.CorrelationId);
}
// ── Audit Log #23 (ExecutionId Task 4): ExecutionId / SourceScript ──
[Fact]
public async Task RetryLoopAttemptedRow_CarriesExecutionIdAndSourceScript_FromContext()
{
// Task 4: the ExecutionId + SourceScript threaded through the S&F
// buffer arrive on the CachedCallAttemptContext; the bridge must stamp
// both onto the per-attempt ApiCallCached row (previously SourceScript
// was hard-coded null with a "not threaded through S&F" comment).
var executionId = Guid.NewGuid();
var captured = new List<CachedCallTelemetry>();
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured.Add(t)), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var sut = CreateSut();
await sut.OnAttemptCompletedAsync(Ctx(
CachedCallAttemptOutcome.TransientFailure,
executionId: executionId,
sourceScript: "Plant.Pump42/OnTick"));
var packet = Assert.Single(captured);
Assert.Equal(AuditKind.ApiCallCached, packet.Audit.Kind);
Assert.Equal(executionId, packet.Audit.ExecutionId);
Assert.Equal("Plant.Pump42/OnTick", packet.Audit.SourceScript);
}
[Fact]
public async Task RetryLoopCachedResolveRow_CarriesExecutionIdAndSourceScript_FromContext()
{
// The terminal CachedResolve row must also carry the threaded
// provenance so the whole retry-loop lifecycle is correlated.
var executionId = Guid.NewGuid();
var captured = new List<CachedCallTelemetry>();
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured.Add(t)), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var sut = CreateSut();
await sut.OnAttemptCompletedAsync(Ctx(
CachedCallAttemptOutcome.Delivered,
channel: "DbOutbound",
executionId: executionId,
sourceScript: "Plant.Tank/OnAlarm"));
Assert.Equal(2, captured.Count);
var resolve = Assert.Single(captured, p => p.Audit.Kind == AuditKind.CachedResolve);
Assert.Equal(executionId, resolve.Audit.ExecutionId);
Assert.Equal("Plant.Tank/OnAlarm", resolve.Audit.SourceScript);
var attempted = Assert.Single(captured, p => p.Audit.Kind == AuditKind.DbWriteCached);
Assert.Equal(executionId, attempted.Audit.ExecutionId);
Assert.Equal("Plant.Tank/OnAlarm", attempted.Audit.SourceScript);
}
[Fact]
public async Task RetryLoopRow_NullExecutionIdAndSourceScript_RemainNull()
{
// Back-compat: a pre-Task-4 buffered row has no ExecutionId /
// SourceScript; the bridge must leave the audit row's fields null
// rather than throwing.
CachedCallTelemetry? captured = null;
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured = t), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var sut = CreateSut();
await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.TransientFailure));
Assert.NotNull(captured);
Assert.Null(captured!.Audit.ExecutionId);
Assert.Null(captured.Audit.SourceScript);
}
// ── Audit Log #23 (ParentExecutionId Task 6): ParentExecutionId ──
[Fact]
public async Task RetryLoopAttemptedRow_CarriesParentExecutionId_FromContext()
{
// Task 6: the ParentExecutionId threaded through the S&F buffer (the
// inbound-API run that spawned the originating script) arrives on the
// CachedCallAttemptContext; the bridge must stamp it onto the
// per-attempt ApiCallCached row beside ExecutionId.
var parentExecutionId = Guid.NewGuid();
var captured = new List<CachedCallTelemetry>();
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured.Add(t)), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var sut = CreateSut();
await sut.OnAttemptCompletedAsync(Ctx(
CachedCallAttemptOutcome.TransientFailure,
parentExecutionId: parentExecutionId));
var packet = Assert.Single(captured);
Assert.Equal(AuditKind.ApiCallCached, packet.Audit.Kind);
Assert.Equal(parentExecutionId, packet.Audit.ParentExecutionId);
}
[Fact]
public async Task RetryLoopCachedResolveRow_CarriesParentExecutionId_FromContext()
{
// The terminal CachedResolve row must also carry the threaded
// ParentExecutionId so the whole retry-loop lifecycle correlates back
// to the spawning inbound-API execution.
var parentExecutionId = Guid.NewGuid();
var captured = new List<CachedCallTelemetry>();
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured.Add(t)), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var sut = CreateSut();
await sut.OnAttemptCompletedAsync(Ctx(
CachedCallAttemptOutcome.Delivered,
channel: "DbOutbound",
parentExecutionId: parentExecutionId));
Assert.Equal(2, captured.Count);
var resolve = Assert.Single(captured, p => p.Audit.Kind == AuditKind.CachedResolve);
Assert.Equal(parentExecutionId, resolve.Audit.ParentExecutionId);
var attempted = Assert.Single(captured, p => p.Audit.Kind == AuditKind.DbWriteCached);
Assert.Equal(parentExecutionId, attempted.Audit.ParentExecutionId);
}
[Fact]
public async Task RetryLoopRow_NullParentExecutionId_RemainsNull()
{
// Back-compat / non-routed run: the originating script was not spawned
// by an inbound-API request, so ParentExecutionId is null; the bridge
// must leave the audit row's ParentExecutionId null rather than throwing.
CachedCallTelemetry? captured = null;
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured = t), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var sut = CreateSut();
await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.TransientFailure));
Assert.NotNull(captured);
Assert.Null(captured!.Audit.ParentExecutionId);
}
// ── SourceNode-stamping (Task 14) ──
[Fact]
public async Task RetryLoopRow_StampsSourceNode_FromNodeIdentityProvider()
{
// SourceNode-stamping (Task 14): when an INodeIdentityProvider is
// wired the bridge stamps the local node name (node-a/node-b) onto
// the SiteCallOperational.SourceNode column of every emitted packet.
var nodeIdentity = Substitute.For<INodeIdentityProvider>();
nodeIdentity.NodeName.Returns("node-a");
var captured = new List<CachedCallTelemetry>();
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured.Add(t)), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var sut = new CachedCallLifecycleBridge(
_forwarder, NullLogger<CachedCallLifecycleBridge>.Instance, nodeIdentity);
await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.Delivered));
Assert.Equal(2, captured.Count);
Assert.All(captured, p => Assert.Equal("node-a", p.Operational.SourceNode));
}
[Fact]
public async Task RetryLoopRow_NoNodeIdentityProvider_LeavesSourceNodeNull()
{
// When no INodeIdentityProvider is wired (legacy hosts / tests) the
// bridge degrades to a null SourceNode rather than throwing. The
// emitted packet's SourceNode is null so the central row persists NULL.
var captured = new List<CachedCallTelemetry>();
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured.Add(t)), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
// Default CreateSut() does NOT pass a node-identity provider.
var sut = CreateSut();
await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.TransientFailure));
var packet = Assert.Single(captured);
Assert.Null(packet.Operational.SourceNode);
}
[Fact]
public async Task RetryLoopRow_NodeIdentityWithNullNodeName_LeavesSourceNodeNull()
{
// The provider exists but reports a null NodeName (unconfigured). The
// bridge must pass that null through to SourceNode rather than
// falling back to a placeholder.
var nodeIdentity = Substitute.For<INodeIdentityProvider>();
nodeIdentity.NodeName.Returns((string?)null);
var captured = new List<CachedCallTelemetry>();
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured.Add(t)), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var sut = new CachedCallLifecycleBridge(
_forwarder, NullLogger<CachedCallLifecycleBridge>.Instance, nodeIdentity);
await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.TransientFailure));
var packet = Assert.Single(captured);
Assert.Null(packet.Operational.SourceNode);
}
}
@@ -0,0 +1,307 @@
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site.Telemetry;
/// <summary>
/// Bundle E E2 tests for <see cref="CachedCallTelemetryForwarder"/>. The
/// forwarder is the site-side dual emitter: every cached-call lifecycle event
/// writes one <see cref="AuditEvent"/> to <see cref="IAuditWriter"/> and one
/// operational tracking-row mutation to <see cref="IOperationTrackingStore"/>.
/// Audit-emission contract: best-effort — a thrown writer or tracking store
/// must be logged and swallowed; the forwarder must never propagate the
/// exception to the calling script.
/// </summary>
public class CachedCallTelemetryForwarderTests
{
private readonly IAuditWriter _writer = Substitute.For<IAuditWriter>();
private readonly IOperationTrackingStore _tracking = Substitute.For<IOperationTrackingStore>();
private readonly TrackedOperationId _id = TrackedOperationId.New();
private readonly DateTime _now = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
private CachedCallTelemetryForwarder CreateSut() => new(
_writer, _tracking, NullLogger<CachedCallTelemetryForwarder>.Instance);
private CachedCallTelemetry SubmitPacket() =>
new(
Audit: new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = _now,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.CachedSubmit,
CorrelationId = _id.Value,
SourceSiteId = "site-1",
SourceInstanceId = "inst-1",
SourceScript = "ScriptActor:doStuff",
Target = "ERP.GetOrder",
Status = AuditStatus.Submitted,
ForwardState = AuditForwardState.Pending,
},
Operational: new SiteCallOperational(
TrackedOperationId: _id,
Channel: "ApiOutbound",
Target: "ERP.GetOrder",
SourceSite: "site-1",
SourceNode: null,
Status: "Submitted",
RetryCount: 0,
LastError: null,
HttpStatus: null,
CreatedAtUtc: _now,
UpdatedAtUtc: _now,
TerminalAtUtc: null));
private CachedCallTelemetry AttemptedPacket(int retryCount = 1, string? lastError = "HTTP 500", int? httpStatus = 500) =>
new(
Audit: new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = _now,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCallCached,
CorrelationId = _id.Value,
SourceSiteId = "site-1",
Target = "ERP.GetOrder",
Status = AuditStatus.Attempted,
HttpStatus = httpStatus,
ErrorMessage = lastError,
ForwardState = AuditForwardState.Pending,
},
Operational: new SiteCallOperational(
TrackedOperationId: _id,
Channel: "ApiOutbound",
Target: "ERP.GetOrder",
SourceSite: "site-1",
SourceNode: null,
Status: "Attempted",
RetryCount: retryCount,
LastError: lastError,
HttpStatus: httpStatus,
CreatedAtUtc: _now,
UpdatedAtUtc: _now,
TerminalAtUtc: null));
private CachedCallTelemetry ResolvePacket(string status = "Delivered") =>
new(
Audit: new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = _now,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.CachedResolve,
CorrelationId = _id.Value,
SourceSiteId = "site-1",
Target = "ERP.GetOrder",
Status = Enum.Parse<AuditStatus>(status),
ForwardState = AuditForwardState.Pending,
},
Operational: new SiteCallOperational(
TrackedOperationId: _id,
Channel: "ApiOutbound",
Target: "ERP.GetOrder",
SourceSite: "site-1",
SourceNode: null,
Status: status,
RetryCount: 2,
LastError: null,
HttpStatus: null,
CreatedAtUtc: _now,
UpdatedAtUtc: _now,
TerminalAtUtc: _now));
[Fact]
public async Task ForwardAsync_Submit_WritesAuditEvent_AndRecordsEnqueue()
{
var sut = CreateSut();
var packet = SubmitPacket();
await sut.ForwardAsync(packet, CancellationToken.None);
// Audit row: one WriteAsync of the submit event.
await _writer.Received(1).WriteAsync(
Arg.Is<AuditEvent>(e =>
e.EventId == packet.Audit.EventId
&& e.Kind == AuditKind.CachedSubmit
&& e.Status == AuditStatus.Submitted),
Arg.Any<CancellationToken>());
// Tracking row: insert-if-not-exists with kind discriminator.
// Default CreateSut() does NOT supply an INodeIdentityProvider, so the
// forwarder passes null sourceNode to RecordEnqueueAsync (legacy / test
// host behaviour). The Task 14 stamping path is covered by the
// ForwardAsync_Submit_StampsSourceNode_FromNodeIdentityProvider test
// below.
await _tracking.Received(1).RecordEnqueueAsync(
_id,
"ApiOutbound",
"ERP.GetOrder",
"inst-1",
"ScriptActor:doStuff",
null,
Arg.Any<CancellationToken>());
await _tracking.DidNotReceiveWithAnyArgs().RecordAttemptAsync(
default, default!, default, default, default, default);
await _tracking.DidNotReceiveWithAnyArgs().RecordTerminalAsync(
default, default!, default, default, default);
}
[Fact]
public async Task ForwardAsync_Attempted_WritesAuditEvent_AndRecordsAttempt()
{
var sut = CreateSut();
var packet = AttemptedPacket(retryCount: 2, lastError: "HTTP 503", httpStatus: 503);
await sut.ForwardAsync(packet, CancellationToken.None);
await _writer.Received(1).WriteAsync(
Arg.Is<AuditEvent>(e =>
e.EventId == packet.Audit.EventId
&& e.Kind == AuditKind.ApiCallCached
&& e.Status == AuditStatus.Attempted),
Arg.Any<CancellationToken>());
await _tracking.Received(1).RecordAttemptAsync(
_id, "Attempted", 2, "HTTP 503", 503, Arg.Any<CancellationToken>());
await _tracking.DidNotReceiveWithAnyArgs().RecordEnqueueAsync(
default, default!, default, default, default, default, default);
await _tracking.DidNotReceiveWithAnyArgs().RecordTerminalAsync(
default, default!, default, default, default);
}
[Fact]
public async Task ForwardAsync_Resolve_WritesAuditEvent_AndRecordsTerminal()
{
var sut = CreateSut();
var packet = ResolvePacket("Delivered");
await sut.ForwardAsync(packet, CancellationToken.None);
await _writer.Received(1).WriteAsync(
Arg.Is<AuditEvent>(e =>
e.EventId == packet.Audit.EventId
&& e.Kind == AuditKind.CachedResolve
&& e.Status == AuditStatus.Delivered),
Arg.Any<CancellationToken>());
await _tracking.Received(1).RecordTerminalAsync(
_id, "Delivered", null, null, Arg.Any<CancellationToken>());
await _tracking.DidNotReceiveWithAnyArgs().RecordEnqueueAsync(
default, default!, default, default, default, default, default);
await _tracking.DidNotReceiveWithAnyArgs().RecordAttemptAsync(
default, default!, default, default, default, default);
}
[Fact]
public async Task ForwardAsync_WriterThrows_Logs_DoesNotPropagate()
{
_writer.WriteAsync(Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>())
.Throws(new InvalidOperationException("primary down"));
var sut = CreateSut();
// Must not throw.
await sut.ForwardAsync(SubmitPacket(), CancellationToken.None);
// Tracking still attempted — emission of the two halves is independent
// so a writer outage cannot starve the operational row (and vice-versa).
await _tracking.Received(1).RecordEnqueueAsync(
Arg.Any<TrackedOperationId>(),
Arg.Any<string>(),
Arg.Any<string?>(),
Arg.Any<string?>(),
Arg.Any<string?>(),
Arg.Any<string?>(),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task ForwardAsync_TrackingStoreThrows_Logs_DoesNotPropagate()
{
_tracking.RecordEnqueueAsync(
Arg.Any<TrackedOperationId>(),
Arg.Any<string>(),
Arg.Any<string?>(),
Arg.Any<string?>(),
Arg.Any<string?>(),
Arg.Any<string?>(),
Arg.Any<CancellationToken>())
.Throws(new InvalidOperationException("sqlite locked"));
var sut = CreateSut();
await sut.ForwardAsync(SubmitPacket(), CancellationToken.None);
// Writer still attempted — emission halves are independent.
await _writer.Received(1).WriteAsync(
Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task ForwardAsync_NullPacket_Throws()
{
var sut = CreateSut();
await Assert.ThrowsAsync<ArgumentNullException>(
() => sut.ForwardAsync(null!, CancellationToken.None));
}
// ── SourceNode-stamping (Task 14) ──
[Fact]
public async Task ForwardAsync_Submit_StampsSourceNode_FromNodeIdentityProvider()
{
// SourceNode-stamping (Task 14): when an INodeIdentityProvider is
// wired the forwarder must stamp its NodeName onto the
// RecordEnqueueAsync sourceNode parameter so the tracking row
// captures the originating node (node-a/node-b).
var nodeIdentity = Substitute.For<INodeIdentityProvider>();
nodeIdentity.NodeName.Returns("node-a");
var sut = new CachedCallTelemetryForwarder(
_writer, _tracking, NullLogger<CachedCallTelemetryForwarder>.Instance, nodeIdentity);
await sut.ForwardAsync(SubmitPacket(), CancellationToken.None);
await _tracking.Received(1).RecordEnqueueAsync(
_id,
"ApiOutbound",
"ERP.GetOrder",
"inst-1",
"ScriptActor:doStuff",
"node-a",
Arg.Any<CancellationToken>());
}
[Fact]
public async Task ForwardAsync_Submit_NodeIdentityNullNodeName_PassesNullSourceNode()
{
// The provider exists but reports a null NodeName (unconfigured).
// The forwarder passes that null through to RecordEnqueueAsync rather
// than falling back to a placeholder string.
var nodeIdentity = Substitute.For<INodeIdentityProvider>();
nodeIdentity.NodeName.Returns((string?)null);
var sut = new CachedCallTelemetryForwarder(
_writer, _tracking, NullLogger<CachedCallTelemetryForwarder>.Instance, nodeIdentity);
await sut.ForwardAsync(SubmitPacket(), CancellationToken.None);
await _tracking.Received(1).RecordEnqueueAsync(
_id,
"ApiOutbound",
"ERP.GetOrder",
"inst-1",
"ScriptActor:doStuff",
null,
Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,201 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Google.Protobuf.WellKnownTypes;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site.Telemetry;
/// <summary>
/// Tests for <see cref="ClusterClientSiteAuditClient"/> — the production
/// <see cref="ISiteStreamAuditClient"/> binding wired by the Host for site
/// roles. The client maps the proto-DTO batches produced by
/// <see cref="SiteAuditTelemetryActor"/> into the Akka
/// <see cref="IngestAuditEventsCommand"/> / <see cref="IngestCachedTelemetryCommand"/>
/// messages, Asks the site's <c>SiteCommunicationActor</c> (which forwards over
/// ClusterClient to central), and maps the reply back into an
/// <see cref="IngestAck"/>.
/// </summary>
/// <remarks>
/// A <see cref="TestProbe"/> stands in for the <c>SiteCommunicationActor</c>:
/// it lets the tests assert the exact command shape AND drive the reply (or
/// withhold one to exercise the Ask-timeout path).
/// </remarks>
public class ClusterClientSiteAuditClientTests : TestKit
{
/// <summary>Short Ask timeout so the timeout test completes quickly.</summary>
private static readonly TimeSpan AskTimeout = TimeSpan.FromMilliseconds(500);
private static AuditEvent NewEvent(Guid? id = null) => new()
{
EventId = id ?? Guid.NewGuid(),
OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
SourceSiteId = "site-1",
ForwardState = AuditForwardState.Pending,
};
private static AuditEventBatch BatchOf(IEnumerable<AuditEvent> events)
{
var batch = new AuditEventBatch();
foreach (var e in events)
{
batch.Events.Add(AuditEventDtoMapper.ToDto(e));
}
return batch;
}
private static SiteCallOperationalDto NewOperationalDto() => new()
{
TrackedOperationId = Guid.NewGuid().ToString(),
Channel = "ApiOutbound",
Target = "ext-system-1",
SourceSite = "site-1",
Status = "Submitted",
RetryCount = 0,
LastError = string.Empty,
CreatedAtUtc = Timestamp.FromDateTime(new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc)),
UpdatedAtUtc = Timestamp.FromDateTime(new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc)),
};
[Fact]
public async Task IngestAuditEventsAsync_FullAck_MapsAllAcceptedIdsOntoAck()
{
var probe = CreateTestProbe();
var sut = new ClusterClientSiteAuditClient(probe.Ref, AskTimeout);
var events = Enumerable.Range(0, 3).Select(_ => NewEvent()).ToList();
var batch = BatchOf(events);
var task = sut.IngestAuditEventsAsync(batch, CancellationToken.None);
// The probe receives exactly one IngestAuditEventsCommand carrying the
// batch's events; it replies with every EventId accepted.
var cmd = probe.ExpectMsg<IngestAuditEventsCommand>(TimeSpan.FromSeconds(3));
Assert.Equal(3, cmd.Events.Count);
Assert.Equal(
events.Select(e => e.EventId).ToHashSet(),
cmd.Events.Select(e => e.EventId).ToHashSet());
probe.Reply(new IngestAuditEventsReply(events.Select(e => e.EventId).ToList()));
var ack = await task;
Assert.Equal(
events.Select(e => e.EventId.ToString()).ToHashSet(),
ack.AcceptedEventIds.ToHashSet());
}
[Fact]
public async Task IngestAuditEventsAsync_PartialAck_OnlyAcceptedIdsAppearOnAck()
{
var probe = CreateTestProbe();
var sut = new ClusterClientSiteAuditClient(probe.Ref, AskTimeout);
var events = Enumerable.Range(0, 5).Select(_ => NewEvent()).ToList();
var accepted = events.Take(3).Select(e => e.EventId).ToList();
var task = sut.IngestAuditEventsAsync(BatchOf(events), CancellationToken.None);
probe.ExpectMsg<IngestAuditEventsCommand>(TimeSpan.FromSeconds(3));
probe.Reply(new IngestAuditEventsReply(accepted));
var ack = await task;
Assert.Equal(3, ack.AcceptedEventIds.Count);
Assert.Equal(
accepted.Select(id => id.ToString()).ToHashSet(),
ack.AcceptedEventIds.ToHashSet());
}
[Fact]
public async Task IngestAuditEventsAsync_AskTimeout_Throws_SoDrainLoopKeepsRowsPending()
{
var probe = CreateTestProbe();
var sut = new ClusterClientSiteAuditClient(probe.Ref, AskTimeout);
var batch = BatchOf(new[] { NewEvent() });
// The probe receives the command but never replies — the Ask times out.
// The contract: a timeout MUST surface as a thrown exception so the
// SiteAuditTelemetryActor drain loop leaves the rows Pending.
var task = sut.IngestAuditEventsAsync(batch, CancellationToken.None);
probe.ExpectMsg<IngestAuditEventsCommand>(TimeSpan.FromSeconds(3));
await Assert.ThrowsAnyAsync<Exception>(() => task);
}
[Fact]
public async Task IngestAuditEventsAsync_FaultedReply_Throws()
{
var probe = CreateTestProbe();
var sut = new ClusterClientSiteAuditClient(probe.Ref, AskTimeout);
var task = sut.IngestAuditEventsAsync(BatchOf(new[] { NewEvent() }), CancellationToken.None);
probe.ExpectMsg<IngestAuditEventsCommand>(TimeSpan.FromSeconds(3));
// A Status.Failure from central (Task 1: central does not swallow an
// ingest fault into an empty ack) must propagate as a thrown exception.
probe.Reply(new Status.Failure(new InvalidOperationException("central ingest faulted")));
await Assert.ThrowsAnyAsync<Exception>(() => task);
}
[Fact]
public async Task IngestCachedTelemetryAsync_RoutesCommand_AndMapsReply()
{
var probe = CreateTestProbe();
var sut = new ClusterClientSiteAuditClient(probe.Ref, AskTimeout);
var events = Enumerable.Range(0, 2).Select(_ => NewEvent()).ToList();
var batch = new CachedTelemetryBatch();
foreach (var e in events)
{
batch.Packets.Add(new CachedTelemetryPacket
{
AuditEvent = AuditEventDtoMapper.ToDto(e),
Operational = NewOperationalDto(),
});
}
var task = sut.IngestCachedTelemetryAsync(batch, CancellationToken.None);
// The probe receives an IngestCachedTelemetryCommand (NOT an
// IngestAuditEventsCommand) with one entry per packet.
var cmd = probe.ExpectMsg<IngestCachedTelemetryCommand>(TimeSpan.FromSeconds(3));
Assert.Equal(2, cmd.Entries.Count);
Assert.Equal(
events.Select(e => e.EventId).ToHashSet(),
cmd.Entries.Select(en => en.Audit.EventId).ToHashSet());
probe.Reply(new IngestCachedTelemetryReply(events.Select(e => e.EventId).ToList()));
var ack = await task;
Assert.Equal(
events.Select(e => e.EventId.ToString()).ToHashSet(),
ack.AcceptedEventIds.ToHashSet());
}
[Fact]
public async Task IngestCachedTelemetryAsync_AskTimeout_Throws()
{
var probe = CreateTestProbe();
var sut = new ClusterClientSiteAuditClient(probe.Ref, AskTimeout);
var batch = new CachedTelemetryBatch();
batch.Packets.Add(new CachedTelemetryPacket
{
AuditEvent = AuditEventDtoMapper.ToDto(NewEvent()),
Operational = NewOperationalDto(),
});
var task = sut.IngestCachedTelemetryAsync(batch, CancellationToken.None);
probe.ExpectMsg<IngestCachedTelemetryCommand>(TimeSpan.FromSeconds(3));
await Assert.ThrowsAnyAsync<Exception>(() => task);
}
}
@@ -0,0 +1,58 @@
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site.Telemetry;
/// <summary>
/// Bundle E E1 tests for <see cref="NoOpSiteStreamAuditClient"/>. The NoOp
/// client is the default <see cref="ISiteStreamAuditClient"/> binding until M6
/// delivers the gRPC-backed implementation; both <c>IngestAuditEventsAsync</c>
/// (M2) and <c>IngestCachedTelemetryAsync</c> (M3) must return an empty ack
/// (no rows flipped to Forwarded) without throwing or partially handling the
/// batch.
/// </summary>
public class NoOpSiteStreamAuditClientTests
{
[Fact]
public async Task IngestCachedTelemetryAsync_EmptyBatch_ReturnsEmptyAck()
{
var sut = new NoOpSiteStreamAuditClient();
var batch = new CachedTelemetryBatch();
var ack = await sut.IngestCachedTelemetryAsync(batch, CancellationToken.None);
Assert.NotNull(ack);
Assert.Empty(ack.AcceptedEventIds);
}
[Fact]
public async Task IngestCachedTelemetryAsync_PopulatedBatch_ReturnsEmptyAck()
{
var sut = new NoOpSiteStreamAuditClient();
var batch = new CachedTelemetryBatch();
batch.Packets.Add(new CachedTelemetryPacket
{
AuditEvent = new AuditEventDto
{
EventId = Guid.NewGuid().ToString(),
Channel = "ApiOutbound",
Kind = "CachedSubmit",
Status = "Submitted",
},
});
var ack = await sut.IngestCachedTelemetryAsync(batch, CancellationToken.None);
// No EventIds flipped — NoOp does not forward to anyone.
Assert.Empty(ack.AcceptedEventIds);
}
[Fact]
public async Task IngestCachedTelemetryAsync_NullBatch_Throws()
{
var sut = new NoOpSiteStreamAuditClient();
await Assert.ThrowsAsync<ArgumentNullException>(
() => sut.IngestCachedTelemetryAsync(null!, CancellationToken.None));
}
}
@@ -0,0 +1,456 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Google.Protobuf.WellKnownTypes;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site.Telemetry;
/// <summary>
/// Bundle D D1 tests for <see cref="SiteAuditTelemetryActor"/>. The actor drains
/// the site SQLite queue via <see cref="ISiteAuditQueue"/>, pushes batches via
/// <see cref="ISiteStreamAuditClient"/>, and flips ack'd rows to Forwarded.
/// Both collaborators are NSubstitute mocks so the tests never touch real
/// SQLite or gRPC.
/// </summary>
public class SiteAuditTelemetryActorTests : TestKit
{
private readonly ISiteAuditQueue _queue = Substitute.For<ISiteAuditQueue>();
private readonly ISiteStreamAuditClient _client = Substitute.For<ISiteStreamAuditClient>();
private readonly IOperationTrackingStore _trackingStore = Substitute.For<IOperationTrackingStore>();
/// <summary>
/// Fast options so tests don't stall waiting for the scheduler. 1s busy /
/// 2s idle still exercises the busy-vs-idle branching, but each test
/// completes in &lt; 5 s wall-clock.
/// </summary>
private static IOptions<SiteAuditTelemetryOptions> Opts(
int batchSize = 256,
int busySeconds = 1,
int idleSeconds = 2) =>
Options.Create(new SiteAuditTelemetryOptions
{
BatchSize = batchSize,
BusyIntervalSeconds = busySeconds,
IdleIntervalSeconds = idleSeconds,
});
private IActorRef CreateActor(IOptions<SiteAuditTelemetryOptions>? options = null) =>
Sys.ActorOf(Props.Create(() => new SiteAuditTelemetryActor(
_queue,
_client,
options ?? Opts(),
NullLogger<SiteAuditTelemetryActor>.Instance,
(IOperationTrackingStore?)null)));
/// <summary>
/// AuditLog-001: builds an actor with the optional
/// <see cref="IOperationTrackingStore"/> wired in so the cached-drain
/// scheduler is armed alongside the audit-only drain. Used by the new
/// cached-drain regression tests below.
/// </summary>
private IActorRef CreateActorWithCachedDrain(IOptions<SiteAuditTelemetryOptions>? options = null) =>
Sys.ActorOf(Props.Create(() => new SiteAuditTelemetryActor(
_queue,
_client,
options ?? Opts(),
NullLogger<SiteAuditTelemetryActor>.Instance,
(IOperationTrackingStore?)_trackingStore)));
private static AuditEvent NewEvent(Guid? id = null) => new()
{
EventId = id ?? Guid.NewGuid(),
OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
SourceSiteId = "site-1",
ForwardState = AuditForwardState.Pending,
};
private static IngestAck AckAll(IReadOnlyList<AuditEvent> events)
{
var ack = new IngestAck();
foreach (var e in events)
{
ack.AcceptedEventIds.Add(e.EventId.ToString());
}
return ack;
}
[Fact]
public async Task Drain_With_50PendingRows_Sends_OneBatch_Of_50_Then_FlipsToForwarded()
{
// Arrange — 50 pending rows on the first read, then empty on subsequent
// reads so the actor settles after one productive drain.
var pending = Enumerable.Range(0, 50).Select(_ => NewEvent()).ToList();
_queue.ReadPendingAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(
Task.FromResult<IReadOnlyList<AuditEvent>>(pending),
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
AuditEventBatch? capturedBatch = null;
_client.IngestAuditEventsAsync(Arg.Any<AuditEventBatch>(), Arg.Any<CancellationToken>())
.Returns(call =>
{
capturedBatch = call.Arg<AuditEventBatch>();
return Task.FromResult(AckAll(pending));
});
// Act
CreateActor();
// Assert — give the scheduler time to fire the initial Drain tick.
await AwaitAssertAsync(async () =>
{
await _client.Received(1).IngestAuditEventsAsync(
Arg.Any<AuditEventBatch>(), Arg.Any<CancellationToken>());
await _queue.Received(1).MarkForwardedAsync(
Arg.Is<IReadOnlyList<Guid>>(g => g.Count == 50), Arg.Any<CancellationToken>());
}, TimeSpan.FromSeconds(5));
Assert.NotNull(capturedBatch);
Assert.Equal(50, capturedBatch!.Events.Count);
var expected = pending.Select(e => e.EventId).ToHashSet();
await _queue.Received(1).MarkForwardedAsync(
Arg.Is<IReadOnlyList<Guid>>(g => g.ToHashSet().SetEquals(expected)),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Drain_GrpcThrows_RowsStayPending_NextDrainRetries()
{
// Arrange — first read returns 3 rows; the gRPC client throws on the
// first push, then succeeds on the second. After the second push the
// queue returns empty so the actor settles.
var batch = Enumerable.Range(0, 3).Select(_ => NewEvent()).ToList();
_queue.ReadPendingAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(
Task.FromResult<IReadOnlyList<AuditEvent>>(batch),
Task.FromResult<IReadOnlyList<AuditEvent>>(batch),
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
var calls = 0;
_client.IngestAuditEventsAsync(Arg.Any<AuditEventBatch>(), Arg.Any<CancellationToken>())
.Returns(_ =>
{
calls++;
if (calls == 1)
{
throw new InvalidOperationException("simulated gRPC failure");
}
return Task.FromResult(AckAll(batch));
});
// Act
CreateActor();
// Assert — eventually MarkForwardedAsync is called exactly once (after
// the retry succeeded). The first failure must NOT have called
// MarkForwardedAsync because the rows stay Pending.
await AwaitAssertAsync(async () =>
{
await _queue.Received(1).MarkForwardedAsync(
Arg.Any<IReadOnlyList<Guid>>(), Arg.Any<CancellationToken>());
}, TimeSpan.FromSeconds(10));
Assert.True(calls >= 2, $"Expected at least 2 client calls (1 failure + 1 retry); saw {calls}");
}
[Fact]
public async Task Drain_ZeroPending_SchedulesAtIdleInterval_NoClientCall()
{
// Arrange — queue always empty.
_queue.ReadPendingAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
// Idle interval = 2 s. Pause 3 s after the first tick (1 s busy on
// PreStart) and assert the empty-queue branch did NOT push to the
// client.
CreateActor(Opts(busySeconds: 1, idleSeconds: 2));
// Allow the initial tick (~1 s) + a generous window for the idle re-tick.
await Task.Delay(TimeSpan.FromSeconds(3));
await _client.DidNotReceiveWithAnyArgs().IngestAuditEventsAsync(default!, default);
// ReadPendingAsync was called at least once (initial tick), and at
// most twice within the 3 s window (initial + one idle re-tick).
var readCalls = _queue.ReceivedCalls()
.Count(c => c.GetMethodInfo().Name == nameof(ISiteAuditQueue.ReadPendingAsync));
Assert.InRange(readCalls, 1, 2);
}
[Fact]
public async Task Drain_NonZeroPending_SchedulesAtBusyInterval()
{
// Arrange — every read returns 1 row. With busy=1s the actor should
// re-drain quickly, producing multiple client calls inside a short
// window.
var single = new List<AuditEvent> { NewEvent() };
_queue.ReadPendingAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(single));
_client.IngestAuditEventsAsync(Arg.Any<AuditEventBatch>(), Arg.Any<CancellationToken>())
.Returns(call => Task.FromResult(AckAll(single)));
CreateActor(Opts(busySeconds: 1, idleSeconds: 10));
// 3-second window with busy=1s should fit at least 2 drains.
await Task.Delay(TimeSpan.FromSeconds(3));
var pushCalls = _client.ReceivedCalls()
.Count(c => c.GetMethodInfo().Name == nameof(ISiteStreamAuditClient.IngestAuditEventsAsync));
Assert.True(pushCalls >= 2,
$"Expected ≥2 pushes within 3s when busy=1s; saw {pushCalls}");
}
[Fact]
public async Task Drain_AcceptedEventIdsSubset_OnlyMarksAccepted()
{
// Arrange — 5 rows pushed, but the central ack only lists 3.
var rows = Enumerable.Range(0, 5).Select(_ => NewEvent()).ToList();
var ackedIds = rows.Take(3).Select(r => r.EventId).ToList();
_queue.ReadPendingAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(
Task.FromResult<IReadOnlyList<AuditEvent>>(rows),
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
var partialAck = new IngestAck();
foreach (var id in ackedIds)
{
partialAck.AcceptedEventIds.Add(id.ToString());
}
_client.IngestAuditEventsAsync(Arg.Any<AuditEventBatch>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(partialAck));
// Act
CreateActor();
await AwaitAssertAsync(async () =>
{
await _queue.Received(1).MarkForwardedAsync(
Arg.Any<IReadOnlyList<Guid>>(), Arg.Any<CancellationToken>());
}, TimeSpan.FromSeconds(5));
// Assert — exactly the 3 ack'd ids made it to MarkForwardedAsync, not
// the other 2.
var ackedSet = ackedIds.ToHashSet();
await _queue.Received(1).MarkForwardedAsync(
Arg.Is<IReadOnlyList<Guid>>(g => g.Count == 3 && g.ToHashSet().SetEquals(ackedSet)),
Arg.Any<CancellationToken>());
}
// ────────────────────────────────────────────────────────────────────────
// AuditLog-001: combined-telemetry cached-drain regression tests. Verify
// that the production wiring of the previously-unreachable cached transport
// routes cached rows through ReadPendingCachedTelemetryAsync +
// IngestCachedTelemetryAsync (and NOT IngestAuditEventsAsync), and that
// orphaned audit rows (no tracking snapshot) are logged + skipped rather
// than crashing the drain.
// ────────────────────────────────────────────────────────────────────────
private static AuditEvent NewCachedEvent(
AuditKind kind = AuditKind.CachedSubmit,
Guid? eventId = null,
Guid? correlationId = null,
string sourceSiteId = "site-1") => new()
{
EventId = eventId ?? Guid.NewGuid(),
OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound,
Kind = kind,
Status = AuditStatus.Submitted,
SourceSiteId = sourceSiteId,
Target = "ERP.GetOrder",
CorrelationId = correlationId ?? Guid.NewGuid(),
ForwardState = AuditForwardState.Pending,
};
private static TrackingStatusSnapshot NewSnapshot(
TrackedOperationId id,
string status = "Submitted",
int retryCount = 0) => new(
Id: id,
Kind: nameof(AuditKind.ApiCallCached),
TargetSummary: "ERP.GetOrder",
Status: status,
RetryCount: retryCount,
LastError: null,
HttpStatus: null,
CreatedAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
UpdatedAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
TerminalAtUtc: null,
SourceInstanceId: "instance-1",
SourceScript: "script-1",
SourceNode: "node-a");
[Fact]
public async Task CachedDrain_CachedRows_RouteThrough_IngestCachedTelemetry_NotIngestAuditEvents()
{
// Arrange — three cached audit rows on the cached queue, each with a
// matching tracking snapshot. The audit-only queue is empty (those
// rows are excluded by ReadPendingAsync after AuditLog-001).
var cachedRows = new[]
{
NewCachedEvent(AuditKind.CachedSubmit),
NewCachedEvent(AuditKind.ApiCallCached),
NewCachedEvent(AuditKind.CachedResolve),
};
_queue.ReadPendingAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
_queue.ReadPendingCachedTelemetryAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(
Task.FromResult<IReadOnlyList<AuditEvent>>(cachedRows),
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
foreach (var row in cachedRows)
{
var tid = new TrackedOperationId(row.CorrelationId!.Value);
_trackingStore.GetStatusAsync(tid, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<TrackingStatusSnapshot?>(NewSnapshot(tid)));
}
CachedTelemetryBatch? capturedBatch = null;
_client.IngestCachedTelemetryAsync(Arg.Any<CachedTelemetryBatch>(), Arg.Any<CancellationToken>())
.Returns(call =>
{
capturedBatch = call.Arg<CachedTelemetryBatch>();
var ack = new IngestAck();
foreach (var packet in capturedBatch.Packets)
{
ack.AcceptedEventIds.Add(packet.AuditEvent.EventId);
}
return Task.FromResult(ack);
});
// Act
CreateActorWithCachedDrain();
// Assert — exactly one IngestCachedTelemetryAsync push containing all
// three packets, and zero IngestAuditEventsAsync pushes (the audit-only
// drain saw an empty queue).
await AwaitAssertAsync(async () =>
{
await _client.Received(1).IngestCachedTelemetryAsync(
Arg.Any<CachedTelemetryBatch>(), Arg.Any<CancellationToken>());
await _queue.Received(1).MarkForwardedAsync(
Arg.Is<IReadOnlyList<Guid>>(g => g.Count == 3), Arg.Any<CancellationToken>());
}, TimeSpan.FromSeconds(5));
Assert.NotNull(capturedBatch);
Assert.Equal(3, capturedBatch!.Packets.Count);
await _client.DidNotReceiveWithAnyArgs().IngestAuditEventsAsync(default!, default);
var emittedEventIds = capturedBatch.Packets
.Select(p => Guid.Parse(p.AuditEvent.EventId))
.ToHashSet();
var expectedIds = cachedRows.Select(r => r.EventId).ToHashSet();
Assert.Equal(expectedIds, emittedEventIds);
}
[Fact]
public async Task CachedDrain_OrphanRow_NoTrackingSnapshot_IsSkipped_DoesNotCrash()
{
// Arrange — two cached audit rows: one with a tracking snapshot, one
// orphaned (the tracking store returns null). The orphaned row must be
// skipped without aborting the batch — the valid row still flows.
var orphan = NewCachedEvent(AuditKind.CachedSubmit);
var valid = NewCachedEvent(AuditKind.CachedResolve);
_queue.ReadPendingAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
_queue.ReadPendingCachedTelemetryAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(
Task.FromResult<IReadOnlyList<AuditEvent>>(new[] { orphan, valid }),
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
// orphan: tracking returns null
_trackingStore.GetStatusAsync(
new TrackedOperationId(orphan.CorrelationId!.Value),
Arg.Any<CancellationToken>())
.Returns(Task.FromResult<TrackingStatusSnapshot?>(null));
// valid: tracking returns a snapshot
var validTid = new TrackedOperationId(valid.CorrelationId!.Value);
_trackingStore.GetStatusAsync(validTid, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<TrackingStatusSnapshot?>(NewSnapshot(validTid, "Delivered")));
CachedTelemetryBatch? capturedBatch = null;
_client.IngestCachedTelemetryAsync(Arg.Any<CachedTelemetryBatch>(), Arg.Any<CancellationToken>())
.Returns(call =>
{
capturedBatch = call.Arg<CachedTelemetryBatch>();
var ack = new IngestAck();
foreach (var packet in capturedBatch.Packets)
{
ack.AcceptedEventIds.Add(packet.AuditEvent.EventId);
}
return Task.FromResult(ack);
});
// Act
CreateActorWithCachedDrain();
// Assert — exactly one push containing ONLY the valid row; the orphan
// is skipped and stays Pending (not in MarkForwardedAsync's id list).
await AwaitAssertAsync(async () =>
{
await _client.Received(1).IngestCachedTelemetryAsync(
Arg.Any<CachedTelemetryBatch>(), Arg.Any<CancellationToken>());
}, TimeSpan.FromSeconds(5));
Assert.NotNull(capturedBatch);
Assert.Single(capturedBatch!.Packets);
Assert.Equal(valid.EventId.ToString(), capturedBatch.Packets[0].AuditEvent.EventId);
await _queue.Received(1).MarkForwardedAsync(
Arg.Is<IReadOnlyList<Guid>>(g => g.Count == 1 && g[0] == valid.EventId),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task AuditOnlyDrain_StillFlows_When_CachedDrain_IsDisabled()
{
// Arrange — ordinary (non-cached) audit rows on the audit-only queue;
// the actor is constructed WITHOUT a tracking store so the cached
// scheduler is never armed. Regression guard against the audit-only
// drain regressing during the AuditLog-001 refactor.
var rows = Enumerable.Range(0, 3).Select(_ => NewEvent()).ToList();
_queue.ReadPendingAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(
Task.FromResult<IReadOnlyList<AuditEvent>>(rows),
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
_client.IngestAuditEventsAsync(Arg.Any<AuditEventBatch>(), Arg.Any<CancellationToken>())
.Returns(_ => Task.FromResult(AckAll(rows)));
// Act — note: CreateActor (no tracking store), not CreateActorWithCachedDrain.
CreateActor();
// Assert — audit-only drain flows normally; the cached client is
// never called and ReadPendingCachedTelemetryAsync is never queried.
await AwaitAssertAsync(async () =>
{
await _client.Received(1).IngestAuditEventsAsync(
Arg.Any<AuditEventBatch>(), Arg.Any<CancellationToken>());
await _queue.Received(1).MarkForwardedAsync(
Arg.Is<IReadOnlyList<Guid>>(g => g.Count == 3), Arg.Any<CancellationToken>());
}, TimeSpan.FromSeconds(5));
await _client.DidNotReceiveWithAnyArgs().IngestCachedTelemetryAsync(default!, default);
await _queue.DidNotReceiveWithAnyArgs().ReadPendingCachedTelemetryAsync(default, default);
}
}
@@ -0,0 +1,20 @@
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
/// <summary>
/// Test fake for <see cref="INodeIdentityProvider"/>. Returns the configured
/// <see cref="NodeName"/> verbatim — including <c>null</c> — so tests can
/// exercise both the "stamped" and "unconfigured" branches of the SourceNode
/// stamping logic in <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Site.SqliteAuditWriter"/>
/// and <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Central.CentralAuditWriter"/>.
/// </summary>
internal sealed class FakeNodeIdentityProvider : INodeIdentityProvider
{
public string? NodeName { get; }
public FakeNodeIdentityProvider(string? nodeName = null)
{
NodeName = nodeName;
}
}
@@ -0,0 +1,84 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Akka.TestKit.Xunit2" />
<PackageReference Include="coverlet.collector" />
<!--
Bundle D D2 needs Microsoft.Data.SqlClient for the MsSqlMigrationFixture
(mirroring ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests). Pinning 6.1.1 here for
the same reason: EF SqlServer 10.0.7 needs >= 6.1.1 but the central pin
is 6.0.2 (production ExternalSystemGateway). Override is test-only.
-->
<PackageReference Include="Microsoft.Data.SqlClient" VersionOverride="6.1.1" />
<PackageReference Include="Microsoft.Data.Sqlite" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
<!--
M4 Bundle E (Task E3): Microsoft.Extensions.Configuration.Json,
DependencyInjection, and Logging.Abstractions are now provided by the
Microsoft.AspNetCore.App framework reference below (pulled in for the
TestHost-based middleware E2E) so we drop them as explicit package
references to satisfy the warn-as-error pruning rule.
-->
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<!--
SkippableFact pattern (xunit 2.9.x has no native Assert.Skip) — used by
Bundle D D2 MSSQL-backed AuditLogIngestActor tests to report Skipped when
the dev MSSQL container is not reachable.
-->
<PackageReference Include="Xunit.SkippableFact" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.AuditLog/ZB.MOM.WW.ScadaBridge.AuditLog.csproj" />
<!--
D2: the AuditLogIngestActor tests use the real AuditLogRepository against
a per-test MSSQL database via MsSqlMigrationFixture. The fixture lives in
ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests; we reference that test project so
the fixture + EF migrations come along without duplicating them.
-->
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.csproj" />
<!--
G2/G3/G4: the cached-call combined telemetry integration tests compose the
production OperationTrackingStore (site SQLite source of truth for
Tracking.Status) alongside the M2 audit writer chain, so the harness
needs a project reference to SiteRuntime where the store lives.
-->
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ZB.MOM.WW.ScadaBridge.SiteRuntime.csproj" />
<!--
M4 Bundle E (Task E2): the dispatcher audit-trail end-to-end test
constructs the production NotificationOutboxActor against the real
CentralAuditWriter so the Attempted/Delivered NotifyDeliver rows land in
the central MSSQL AuditLog table.
-->
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/ZB.MOM.WW.ScadaBridge.NotificationOutbox.csproj" />
<!--
M4 Bundle E (Task E3): the inbound API audit-trail end-to-end test wires
the production AuditWriteMiddleware into a TestHost pipeline and asserts
one InboundRequest/InboundAuthFailure row per request lands in the
central MSSQL AuditLog.
-->
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.InboundAPI/ZB.MOM.WW.ScadaBridge.InboundAPI.csproj" />
</ItemGroup>
<ItemGroup>
<!-- M4 Bundle E (Task E3): need ASP.NET Core for the TestHost-based middleware E2E. -->
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
</ItemGroup>
</Project>