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;
using ScadaLink.StoreAndForward;
namespace ScadaLink.Host.Tests;
///
/// 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
/// includes the dedicated
/// audit-telemetry-dispatcher the site telemetry actor binds to.
///
///
///
/// Full cluster bring-up is exercised by the existing
/// pattern — these tests reuse the same
/// trick to short-circuit
/// so DI resolution is exercised
/// without the actor system actually being created.
///
///
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 { "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);
}
}
///
/// Verifies Audit Log (#23) services land in the Central composition root.
///
public class CentralAuditWiringTests : IDisposable
{
private readonly WebApplicationFactory _factory;
private readonly string? _previousEnv;
public CentralAuditWiringTests()
{
_previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
_factory = new WebApplicationFactory()
.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary
{
["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) ||
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(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();
Assert.NotNull(writer);
Assert.IsType(writer);
}
[Fact]
public void Central_Resolves_AuditLogOptions()
{
var opts = _factory.Services.GetService>();
Assert.NotNull(opts);
Assert.NotNull(opts!.Value);
}
[Fact]
public void Central_Resolves_SqliteAuditWriterOptions()
{
var opts = _factory.Services.GetService>();
Assert.NotNull(opts);
Assert.NotNull(opts!.Value);
}
[Fact]
public void Central_Resolves_SiteAuditTelemetryOptions()
{
var opts = _factory.Services.GetService>();
Assert.NotNull(opts);
Assert.NotNull(opts!.Value);
}
[Fact]
public void Central_Resolves_ISiteStreamAuditClient_AsNoOpDefault()
{
var client = _factory.Services.GetService();
Assert.NotNull(client);
Assert.IsType(client);
}
///
/// M3 Bundle F (T15): the Central composition root calls
/// AddSiteCallAudit(). Today that extension is a no-op placeholder,
/// but invoking it must not throw and the central host's service collection
/// must build successfully — the actor's Props are constructed inline in
/// AkkaHostedService (via the root ),
/// not from a DI factory. Asserting the host built confirms the wiring
/// call is in place; this test guards against accidentally removing it
/// from Program.cs.
///
[Fact]
public void Central_HostBuilds_With_AddSiteCallAudit_Wired()
{
// Reaching _factory.Services means WebApplicationFactory built the host
// (DI validation completed). The fact this test is in the
// CentralAuditWiringTests fixture means it ran against the Central
// composition root path through Program.cs.
Assert.NotNull(_factory.Services);
}
///
/// M3 Bundle F: the Central composition root registers
/// ICachedCallTelemetryForwarder as a lazy singleton (the
/// forwarder degrades to audit-only emission when the site-only
/// IOperationTrackingStore is absent, matching the M2 lazy chain
/// pattern). The binding is exercised here so a future regression that
/// removes the registration or makes IOperationTrackingStore mandatory
/// fails on the Central node, not just at first script execution.
///
[Fact]
public void Central_Resolves_ICachedCallTelemetryForwarder_LazySingleton()
{
var forwarder = _factory.Services.GetService();
Assert.NotNull(forwarder);
Assert.IsType(forwarder);
}
}
///
/// Verifies Audit Log (#23) services land in the Site composition root.
///
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
{
["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();
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();
Assert.NotNull(writer);
Assert.IsType(writer);
}
[Fact]
public void Site_Resolves_SqliteAuditWriter_AsSingleton()
{
var a = _host.Services.GetService();
var b = _host.Services.GetService();
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();
var writer = _host.Services.GetService();
Assert.NotNull(queue);
Assert.NotNull(writer);
Assert.Same(writer, queue);
}
[Fact]
public void Site_Resolves_RingBufferFallback()
{
var ring = _host.Services.GetService();
Assert.NotNull(ring);
}
[Fact]
public void Site_Resolves_IAuditWriteFailureCounter_AsHealthMetricsBridge()
{
// Bundle G (M2-T11): site composition root calls
// AddAuditLogHealthMetricsBridge() after AddAuditLog + AddSiteHealthMonitoring,
// which swaps the NoOp default for the real health-metrics bridge so
// FallbackAuditWriter primary failures surface in the site health
// report payload as SiteAuditWriteFailures.
var counter = _host.Services.GetService();
Assert.NotNull(counter);
Assert.IsType(counter);
}
[Fact]
public void Site_Resolves_ISiteStreamAuditClient_AsNoOpDefault()
{
var client = _host.Services.GetService();
Assert.NotNull(client);
Assert.IsType(client);
}
[Fact]
public void Site_Resolves_SiteAuditTelemetryOptions_WithDefaults()
{
var opts = _host.Services.GetService>();
Assert.NotNull(opts);
Assert.Equal(256, opts!.Value.BatchSize);
Assert.Equal(5, opts.Value.BusyIntervalSeconds);
Assert.Equal(30, opts.Value.IdleIntervalSeconds);
}
///
/// M3 Bundle F (T15): the site composition root resolves the cached-call
/// telemetry forwarder. ScriptExecutionActor consumes this through
/// GetService<ICachedCallTelemetryForwarder>() on every script
/// execution; a missing registration would silently degrade
/// ExternalSystem.CachedCall / Database.CachedWrite to the
/// "no-emission" path and break the M3 audit pipeline.
///
[Fact]
public void Site_Resolves_ICachedCallTelemetryForwarder()
{
var forwarder = _host.Services.GetService();
Assert.NotNull(forwarder);
Assert.IsType(forwarder);
}
///
/// M3 Bundle F (T15): the site composition root resolves the lifecycle
/// bridge that translates S&F retry-loop attempt notifications into
/// cached-call telemetry packets.
///
[Fact]
public void Site_Resolves_CachedCallLifecycleBridge_AsSingleton()
{
var a = _host.Services.GetService();
var b = _host.Services.GetService();
Assert.NotNull(a);
Assert.NotNull(b);
Assert.Same(a, b);
}
///
/// M3 Bundle F (T15): the lifecycle bridge is bound to the
/// contract that
/// StoreAndForwardService consults at construction time. Without this
/// binding the S&F service is built with a null observer and the
/// retry-loop telemetry never reaches the audit pipeline.
///
[Fact]
public void Site_ICachedCallLifecycleObserver_IsTheLifecycleBridge()
{
var observer = _host.Services.GetService();
var bridge = _host.Services.GetService();
Assert.NotNull(observer);
Assert.NotNull(bridge);
Assert.Same(bridge, observer);
}
///
/// M3 Bundle F (T15): the Host registers an
/// adapter so the S&F service
/// can resolve the site id at composition time WITHOUT introducing a
/// StoreAndForward → HealthMonitoring project-reference cycle.
///
[Fact]
public void Site_Resolves_IStoreAndForwardSiteContext_FromHost()
{
var ctx = _host.Services.GetService();
Assert.NotNull(ctx);
Assert.Equal("TestSite", ctx!.SiteId);
}
}