Wires Bundle E of the M2 site-sync pipeline: - AddAuditLog extended to register the site writer chain (SqliteAuditWriter singleton + ISiteAuditQueue forward + RingBufferFallback + FallbackAuditWriter composing them) and the telemetry collaborators (SiteAuditTelemetryOptions, SqliteAuditWriterOptions, IAuditWriteFailureCounter NoOp default, ISiteStreamAuditClient NoOp default). - AkkaHostedService central role: AuditLogIngestActor as ClusterSingletonManager (singleton name 'audit-log-ingest') + ClusterSingletonProxy, mirroring the Notification Outbox pattern. Proxy is offered to SiteStreamGrpcServer if it resolves (Site path only today; M6 reconciliation will host gRPC on central). - AkkaHostedService site role: SiteAuditTelemetryActor (per-site, NOT a singleton because each site is its own cluster), bound to a dedicated audit-telemetry-dispatcher (ForkJoinDispatcher, 2 dedicated threads). - Program.cs + SiteServiceRegistration.Configure call AddAuditLog on both roles. - AuditLogIngestActor gains a second constructor that takes IServiceProvider so the cluster singleton can create a fresh scope per message — IAuditLogRepository is a scoped EF Core service and cannot be pre-resolved from the root. The IAuditLogRepository constructor remains for Bundle D's MSSQL-fixture tests. NoOp ISiteStreamAuditClient is deliberate: no site→central gRPC channel exists in M2 (sites talk to central via Akka ClusterClient; gRPC SiteStreamService is hosted on sites for central→site streaming). M6 reconciliation introduces the real gRPC site→central client + central-hosted gRPC server. Bundle H's integration test substitutes a stub client directly via the actor's Props. Tests: - tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs — 11 tests (was 3): writer singleton, IAuditWriter as FallbackAuditWriter, ISiteAuditQueue same-instance as SqliteAuditWriter, options bind round-trip, NoOp default assertions. - tests/ScadaLink.Host.Tests/AkkaHostedServiceAuditWiringTests.cs (new) — 13 tests: BuildHocon emits audit-telemetry-dispatcher block with the expected type/throughput/thread-count; Central composition root resolves the writer chain + options; Site composition root resolves the writer chain + options + NoOp client. Verified: dotnet build clean, 23 test suites green (Host 194 + AuditLog 54).
This commit is contained in:
301
tests/ScadaLink.Host.Tests/AkkaHostedServiceAuditWiringTests.cs
Normal file
301
tests/ScadaLink.Host.Tests/AkkaHostedServiceAuditWiringTests.cs
Normal file
@@ -0,0 +1,301 @@
|
||||
using Akka.Configuration;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ScadaLink.AuditLog;
|
||||
using ScadaLink.AuditLog.Site;
|
||||
using ScadaLink.AuditLog.Site.Telemetry;
|
||||
using ScadaLink.ClusterInfrastructure;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.ConfigurationDatabase;
|
||||
using ScadaLink.Host;
|
||||
using ScadaLink.Host.Actors;
|
||||
|
||||
namespace ScadaLink.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle E (M2 Task E1) — verifies the Audit Log (#23) DI surface is wired
|
||||
/// into both composition roots and that the HOCON document emitted by
|
||||
/// <see cref="AkkaHostedService.BuildHocon"/> includes the dedicated
|
||||
/// <c>audit-telemetry-dispatcher</c> the site telemetry actor binds to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Full cluster bring-up is exercised by the existing
|
||||
/// <see cref="CompositionRootTests"/> pattern — these tests reuse the same
|
||||
/// <see cref="AkkaHostedServiceRemover"/> trick to short-circuit
|
||||
/// <see cref="AkkaHostedService.StartAsync"/> so DI resolution is exercised
|
||||
/// without the actor system actually being created.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class AkkaHostedServiceAuditWiringHoconTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildHocon_Emits_AuditTelemetryDispatcher_Block()
|
||||
{
|
||||
// Bundle E acceptance: the HOCON document the host parses must declare
|
||||
// the dedicated dispatcher the SiteAuditTelemetryActor binds to. A
|
||||
// missing dispatcher block would route the actor to the default
|
||||
// dispatcher and silently lose the isolation guarantee.
|
||||
var nodeOptions = new NodeOptions
|
||||
{
|
||||
Role = "Site",
|
||||
NodeHostname = "site-test-1",
|
||||
RemotingPort = 0,
|
||||
SiteId = "TestSite",
|
||||
};
|
||||
var clusterOptions = new ClusterOptions
|
||||
{
|
||||
SeedNodes = new List<string> { "akka.tcp://scadalink@localhost:2551" },
|
||||
SplitBrainResolverStrategy = "keep-oldest",
|
||||
MinNrOfMembers = 1,
|
||||
StableAfter = TimeSpan.FromSeconds(15),
|
||||
HeartbeatInterval = TimeSpan.FromSeconds(2),
|
||||
FailureDetectionThreshold = TimeSpan.FromSeconds(10),
|
||||
};
|
||||
|
||||
var hocon = AkkaHostedService.BuildHocon(
|
||||
nodeOptions,
|
||||
clusterOptions,
|
||||
new[] { "Site", "site-TestSite" },
|
||||
TimeSpan.FromSeconds(5),
|
||||
TimeSpan.FromSeconds(15));
|
||||
|
||||
var config = ConfigurationFactory.ParseString(hocon);
|
||||
|
||||
// The dispatcher is declared at the root, so the lookup is by its
|
||||
// unqualified name. The HOCON parser must accept the block as a
|
||||
// standalone dispatcher definition the actor system can resolve.
|
||||
var dispatcherType = config.GetString("audit-telemetry-dispatcher.type");
|
||||
Assert.Equal("ForkJoinDispatcher", dispatcherType);
|
||||
|
||||
var throughput = config.GetInt("audit-telemetry-dispatcher.throughput");
|
||||
Assert.Equal(100, throughput);
|
||||
|
||||
var threadCount = config.GetInt("audit-telemetry-dispatcher.dedicated-thread-pool.thread-count");
|
||||
Assert.Equal(2, threadCount);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies Audit Log (#23) services land in the Central composition root.
|
||||
/// </summary>
|
||||
public class CentralAuditWiringTests : IDisposable
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
private readonly string? _previousEnv;
|
||||
|
||||
public CentralAuditWiringTests()
|
||||
{
|
||||
_previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
|
||||
|
||||
_factory = new WebApplicationFactory<Program>()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, config) =>
|
||||
{
|
||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ScadaLink:Node:NodeHostname"] = "localhost",
|
||||
["ScadaLink:Node:RemotingPort"] = "0",
|
||||
["ScadaLink:Cluster:SeedNodes:0"] = "akka.tcp://scadalink@localhost:2551",
|
||||
["ScadaLink:Cluster:SeedNodes:1"] = "akka.tcp://scadalink@localhost:2552",
|
||||
["ScadaLink:Database:SkipMigrations"] = "true",
|
||||
["ScadaLink:Security:JwtSigningKey"] = "test-signing-key-must-be-at-least-32-chars-long!",
|
||||
["ScadaLink:Security:LdapServer"] = "localhost",
|
||||
["ScadaLink:Security:LdapPort"] = "3893",
|
||||
["ScadaLink:Security:LdapUseTls"] = "false",
|
||||
["ScadaLink:Security:AllowInsecureLdap"] = "true",
|
||||
["ScadaLink:Security:LdapSearchBase"] = "dc=scadalink,dc=local",
|
||||
["ScadaLink:InboundApi:ApiKeyPepper"] = "test-inbound-api-key-pepper-at-least-32-chars!",
|
||||
});
|
||||
});
|
||||
builder.UseSetting("ScadaLink:Node:Role", "Central");
|
||||
builder.UseSetting("ScadaLink:Database:SkipMigrations", "true");
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
var descriptorsToRemove = services
|
||||
.Where(d =>
|
||||
d.ServiceType == typeof(DbContextOptions<ScadaLinkDbContext>) ||
|
||||
d.ServiceType == typeof(DbContextOptions) ||
|
||||
d.ServiceType == typeof(ScadaLinkDbContext) ||
|
||||
d.ServiceType.FullName?.Contains("EntityFrameworkCore") == true)
|
||||
.ToList();
|
||||
foreach (var d in descriptorsToRemove)
|
||||
services.Remove(d);
|
||||
|
||||
services.AddDbContext<ScadaLinkDbContext>(options =>
|
||||
options.UseInMemoryDatabase($"CentralAuditWiringTests_{Guid.NewGuid()}"));
|
||||
|
||||
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(services);
|
||||
});
|
||||
});
|
||||
|
||||
_ = _factory.Server;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_factory.Dispose();
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", _previousEnv);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Central_Resolves_IAuditWriter_AsFallbackAuditWriter()
|
||||
{
|
||||
// Central nodes still register the writer chain because AddAuditLog is
|
||||
// shared between roles — the registrations are lazy singletons and the
|
||||
// writer is never resolved on a central node in production. Asserting
|
||||
// it resolves here confirms the chain is intact and ready for the
|
||||
// future case where a central-only actor needs to emit audit events.
|
||||
var writer = _factory.Services.GetService<IAuditWriter>();
|
||||
Assert.NotNull(writer);
|
||||
Assert.IsType<FallbackAuditWriter>(writer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Central_Resolves_AuditLogOptions()
|
||||
{
|
||||
var opts = _factory.Services.GetService<IOptions<ScadaLink.AuditLog.Configuration.AuditLogOptions>>();
|
||||
Assert.NotNull(opts);
|
||||
Assert.NotNull(opts!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Central_Resolves_SqliteAuditWriterOptions()
|
||||
{
|
||||
var opts = _factory.Services.GetService<IOptions<SqliteAuditWriterOptions>>();
|
||||
Assert.NotNull(opts);
|
||||
Assert.NotNull(opts!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Central_Resolves_SiteAuditTelemetryOptions()
|
||||
{
|
||||
var opts = _factory.Services.GetService<IOptions<SiteAuditTelemetryOptions>>();
|
||||
Assert.NotNull(opts);
|
||||
Assert.NotNull(opts!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Central_Resolves_ISiteStreamAuditClient_AsNoOpDefault()
|
||||
{
|
||||
var client = _factory.Services.GetService<ISiteStreamAuditClient>();
|
||||
Assert.NotNull(client);
|
||||
Assert.IsType<NoOpSiteStreamAuditClient>(client);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies Audit Log (#23) services land in the Site composition root.
|
||||
/// </summary>
|
||||
public class SiteAuditWiringTests : IDisposable
|
||||
{
|
||||
private readonly WebApplication _host;
|
||||
private readonly string _tempDbPath;
|
||||
|
||||
public SiteAuditWiringTests()
|
||||
{
|
||||
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadalink_audit_wiring_{Guid.NewGuid()}.db");
|
||||
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.Configuration.Sources.Clear();
|
||||
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ScadaLink:Node:Role"] = "Site",
|
||||
["ScadaLink:Node:NodeHostname"] = "test-site",
|
||||
["ScadaLink:Node:SiteId"] = "TestSite",
|
||||
["ScadaLink:Node:RemotingPort"] = "0",
|
||||
["ScadaLink:Node:GrpcPort"] = "0",
|
||||
["ScadaLink:Database:SiteDbPath"] = _tempDbPath,
|
||||
["ScadaLink:Cluster:SeedNodes:0"] = "akka.tcp://scadalink@localhost:2551",
|
||||
["ScadaLink:Cluster:SeedNodes:1"] = "akka.tcp://scadalink@localhost:2552",
|
||||
// SqliteAuditWriter would attempt to open a SQLite file when first
|
||||
// resolved; point it at an in-memory connection so the test doesn't
|
||||
// pollute the working directory.
|
||||
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
|
||||
});
|
||||
|
||||
builder.Services.AddGrpc();
|
||||
builder.Services.AddSingleton<ScadaLink.Communication.Grpc.SiteStreamGrpcServer>();
|
||||
SiteServiceRegistration.Configure(builder.Services, builder.Configuration);
|
||||
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(builder.Services);
|
||||
|
||||
_host = builder.Build();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
(_host as IDisposable)?.Dispose();
|
||||
try { File.Delete(_tempDbPath); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Site_Resolves_IAuditWriter_AsFallbackAuditWriter()
|
||||
{
|
||||
var writer = _host.Services.GetService<IAuditWriter>();
|
||||
Assert.NotNull(writer);
|
||||
Assert.IsType<FallbackAuditWriter>(writer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Site_Resolves_SqliteAuditWriter_AsSingleton()
|
||||
{
|
||||
var a = _host.Services.GetService<SqliteAuditWriter>();
|
||||
var b = _host.Services.GetService<SqliteAuditWriter>();
|
||||
Assert.NotNull(a);
|
||||
Assert.NotNull(b);
|
||||
Assert.Same(a, b);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Site_ISiteAuditQueue_AndSqliteAuditWriter_AreSameInstance()
|
||||
{
|
||||
// The telemetry actor reads from ISiteAuditQueue while ScriptRuntimeContext
|
||||
// writes through IAuditWriter → SqliteAuditWriter. If these don't resolve
|
||||
// to the same instance, pending rows are invisible to the actor.
|
||||
var queue = _host.Services.GetService<ISiteAuditQueue>();
|
||||
var writer = _host.Services.GetService<SqliteAuditWriter>();
|
||||
Assert.NotNull(queue);
|
||||
Assert.NotNull(writer);
|
||||
Assert.Same(writer, queue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Site_Resolves_RingBufferFallback()
|
||||
{
|
||||
var ring = _host.Services.GetService<RingBufferFallback>();
|
||||
Assert.NotNull(ring);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Site_Resolves_IAuditWriteFailureCounter_AsNoOpDefault()
|
||||
{
|
||||
var counter = _host.Services.GetService<IAuditWriteFailureCounter>();
|
||||
Assert.NotNull(counter);
|
||||
Assert.IsType<NoOpAuditWriteFailureCounter>(counter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Site_Resolves_ISiteStreamAuditClient_AsNoOpDefault()
|
||||
{
|
||||
var client = _host.Services.GetService<ISiteStreamAuditClient>();
|
||||
Assert.NotNull(client);
|
||||
Assert.IsType<NoOpSiteStreamAuditClient>(client);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Site_Resolves_SiteAuditTelemetryOptions_WithDefaults()
|
||||
{
|
||||
var opts = _host.Services.GetService<IOptions<SiteAuditTelemetryOptions>>();
|
||||
Assert.NotNull(opts);
|
||||
Assert.Equal(256, opts!.Value.BatchSize);
|
||||
Assert.Equal(5, opts.Value.BusyIntervalSeconds);
|
||||
Assert.Equal(30, opts.Value.IdleIntervalSeconds);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user