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:
@@ -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);
|
||||
}
|
||||
}
|
||||
+403
@@ -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);
|
||||
}
|
||||
}
|
||||
+154
@@ -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}");
|
||||
}
|
||||
}
|
||||
+98
@@ -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>());
|
||||
}
|
||||
}
|
||||
+453
@@ -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);
|
||||
}
|
||||
}
|
||||
+116
@@ -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"]);
|
||||
}
|
||||
}
|
||||
+222
@@ -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 > 0</c>, <c>ErrorCapBytes >= 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);
|
||||
}
|
||||
}
|
||||
+53
@@ -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));
|
||||
}
|
||||
}
|
||||
+477
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+271
@@ -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&F retry loop; that would
|
||||
/// require a full SiteRuntime host and is out of scope for M3 (the S&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&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);
|
||||
}
|
||||
}
|
||||
+197
@@ -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);
|
||||
}
|
||||
}
|
||||
+201
@@ -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);
|
||||
}
|
||||
}
|
||||
+300
@@ -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));
|
||||
}
|
||||
}
|
||||
+276
@@ -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);
|
||||
}
|
||||
}
|
||||
+125
@@ -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);
|
||||
}
|
||||
+177
@@ -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&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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+150
@@ -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 > 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;
|
||||
}
|
||||
}
|
||||
+352
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+626
@@ -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);
|
||||
}
|
||||
}
|
||||
+272
@@ -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><redacted></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><redacted: redactor error></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>"<redacted>"</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<T></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><redacted: redactor error></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><redacted></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();
|
||||
}
|
||||
}
|
||||
+49
@@ -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!));
|
||||
}
|
||||
}
|
||||
+46
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
+191
@@ -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);
|
||||
}
|
||||
}
|
||||
+394
@@ -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&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);
|
||||
}
|
||||
}
|
||||
+307
@@ -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>());
|
||||
}
|
||||
}
|
||||
+201
@@ -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);
|
||||
}
|
||||
}
|
||||
+58
@@ -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));
|
||||
}
|
||||
}
|
||||
+456
@@ -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 < 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;
|
||||
}
|
||||
}
|
||||
+84
@@ -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>
|
||||
Reference in New Issue
Block a user