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,205 @@
|
||||
using Akka.Actor;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
using ZB.MOM.WW.ScadaBridge.Host;
|
||||
using ZB.MOM.WW.ScadaBridge.Host.Actors;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
[CollectionDefinition("ActorSystem")]
|
||||
public class ActorSystemCollection : ICollectionFixture<object> { }
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that all expected Central-role actors are created at the correct paths
|
||||
/// when AkkaHostedService starts.
|
||||
/// </summary>
|
||||
[Collection("ActorSystem")]
|
||||
public class CentralActorPathTests : IAsyncLifetime
|
||||
{
|
||||
private WebApplicationFactory<Program>? _factory;
|
||||
private ActorSystem? _actorSystem;
|
||||
private string? _previousEnv;
|
||||
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
_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?>
|
||||
{
|
||||
["ScadaBridge:Node:NodeHostname"] = "localhost",
|
||||
["ScadaBridge:Node:RemotingPort"] = "0",
|
||||
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:25510",
|
||||
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:25520",
|
||||
["ScadaBridge:Cluster:MinNrOfMembers"] = "1",
|
||||
["ScadaBridge:Database:SkipMigrations"] = "true",
|
||||
["ScadaBridge:Security:JwtSigningKey"] = "test-signing-key-must-be-at-least-32-chars-long!",
|
||||
["ScadaBridge:Security:LdapServer"] = "localhost",
|
||||
["ScadaBridge:Security:LdapPort"] = "3893",
|
||||
["ScadaBridge:Security:LdapUseTls"] = "false",
|
||||
["ScadaBridge:Security:AllowInsecureLdap"] = "true",
|
||||
["ScadaBridge:Security:LdapSearchBase"] = "dc=scadabridge,dc=local",
|
||||
});
|
||||
});
|
||||
builder.UseSetting("ScadaBridge:Node:Role", "Central");
|
||||
builder.UseSetting("ScadaBridge:Database:SkipMigrations", "true");
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Replace SQL Server with in-memory database
|
||||
var descriptorsToRemove = services
|
||||
.Where(d =>
|
||||
d.ServiceType == typeof(DbContextOptions<ScadaBridgeDbContext>) ||
|
||||
d.ServiceType == typeof(DbContextOptions) ||
|
||||
d.ServiceType == typeof(ScadaBridgeDbContext) ||
|
||||
d.ServiceType.FullName?.Contains("EntityFrameworkCore") == true)
|
||||
.ToList();
|
||||
foreach (var d in descriptorsToRemove)
|
||||
services.Remove(d);
|
||||
|
||||
services.AddDbContext<ScadaBridgeDbContext>(options =>
|
||||
options.UseInMemoryDatabase($"ActorPathTests_{Guid.NewGuid()}"));
|
||||
});
|
||||
});
|
||||
|
||||
// CreateClient triggers host startup including AkkaHostedService
|
||||
_ = _factory.CreateClient();
|
||||
|
||||
var akkaService = _factory.Services.GetRequiredService<AkkaHostedService>();
|
||||
_actorSystem = akkaService.ActorSystem;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
_factory?.Dispose();
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", _previousEnv);
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CentralActors_DeadLetterMonitor_Exists()
|
||||
=> await AssertActorExists("/user/dead-letter-monitor");
|
||||
|
||||
[Fact]
|
||||
public async Task CentralActors_CentralCommunication_Exists()
|
||||
=> await AssertActorExists("/user/central-communication");
|
||||
|
||||
[Fact]
|
||||
public async Task CentralActors_Management_Exists()
|
||||
=> await AssertActorExists("/user/management");
|
||||
|
||||
[Fact]
|
||||
public async Task CentralActors_NotificationOutboxSingleton_Exists()
|
||||
=> await AssertActorExists("/user/notification-outbox-singleton");
|
||||
|
||||
[Fact]
|
||||
public async Task CentralActors_NotificationOutboxProxy_Exists()
|
||||
=> await AssertActorExists("/user/notification-outbox-proxy");
|
||||
|
||||
private async Task AssertActorExists(string path)
|
||||
{
|
||||
Assert.NotNull(_actorSystem);
|
||||
var selection = _actorSystem!.ActorSelection(path);
|
||||
var identity = await selection.Ask<ActorIdentity>(
|
||||
new Identify(path), TimeSpan.FromSeconds(5));
|
||||
Assert.NotNull(identity.Subject);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that all expected Site-role actors are created at the correct paths
|
||||
/// when AkkaHostedService starts.
|
||||
/// </summary>
|
||||
[Collection("ActorSystem")]
|
||||
public class SiteActorPathTests : IAsyncLifetime
|
||||
{
|
||||
private IHost? _host;
|
||||
private ActorSystem? _actorSystem;
|
||||
private string _tempDbPath = null!;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_actor_test_{Guid.NewGuid()}.db");
|
||||
|
||||
var builder = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder();
|
||||
builder.ConfigureAppConfiguration(config =>
|
||||
{
|
||||
config.Sources.Clear();
|
||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ScadaBridge:Node:Role"] = "Site",
|
||||
["ScadaBridge:Node:NodeHostname"] = "localhost",
|
||||
["ScadaBridge:Node:SiteId"] = "TestSite",
|
||||
["ScadaBridge:Node:RemotingPort"] = "0",
|
||||
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:25510",
|
||||
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:25520",
|
||||
["ScadaBridge:Cluster:MinNrOfMembers"] = "1",
|
||||
["ScadaBridge:Database:SiteDbPath"] = _tempDbPath,
|
||||
// Configure a dummy central contact point to trigger ClusterClient creation
|
||||
["ScadaBridge:Communication:CentralContactPoints:0"] = "akka.tcp://scadabridge@localhost:25510",
|
||||
});
|
||||
});
|
||||
builder.ConfigureServices((context, services) =>
|
||||
{
|
||||
SiteServiceRegistration.Configure(services, context.Configuration);
|
||||
});
|
||||
|
||||
_host = builder.Build();
|
||||
await _host.StartAsync();
|
||||
|
||||
var akkaService = _host.Services.GetRequiredService<AkkaHostedService>();
|
||||
_actorSystem = akkaService.ActorSystem;
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_host != null)
|
||||
{
|
||||
await _host.StopAsync();
|
||||
_host.Dispose();
|
||||
}
|
||||
try { File.Delete(_tempDbPath); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SiteActors_DeadLetterMonitor_Exists()
|
||||
=> await AssertActorExists("/user/dead-letter-monitor");
|
||||
|
||||
[Fact]
|
||||
public async Task SiteActors_DclManager_Exists()
|
||||
=> await AssertActorExists("/user/dcl-manager");
|
||||
|
||||
[Fact]
|
||||
public async Task SiteActors_DeploymentManagerSingleton_Exists()
|
||||
=> await AssertActorExists("/user/deployment-manager-singleton");
|
||||
|
||||
[Fact]
|
||||
public async Task SiteActors_DeploymentManagerProxy_Exists()
|
||||
=> await AssertActorExists("/user/deployment-manager-proxy");
|
||||
|
||||
[Fact]
|
||||
public async Task SiteActors_SiteCommunication_Exists()
|
||||
=> await AssertActorExists("/user/site-communication");
|
||||
|
||||
[Fact]
|
||||
public async Task SiteActors_CentralClusterClient_Exists()
|
||||
=> await AssertActorExists("/user/central-cluster-client");
|
||||
|
||||
private async Task AssertActorExists(string path)
|
||||
{
|
||||
Assert.NotNull(_actorSystem);
|
||||
var selection = _actorSystem!.ActorSelection(path);
|
||||
var identity = await selection.Ask<ActorIdentity>(
|
||||
new Identify(path), TimeSpan.FromSeconds(5));
|
||||
Assert.NotNull(identity.Subject);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-13: Tests for Akka.NET actor system bootstrap.
|
||||
/// </summary>
|
||||
public class AkkaBootstrapTests : IDisposable
|
||||
{
|
||||
private ActorSystem? _actorSystem;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_actorSystem?.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActorSystem_CreatesWithClusterConfig()
|
||||
{
|
||||
var hocon = @"
|
||||
akka {
|
||||
actor {
|
||||
provider = cluster
|
||||
}
|
||||
remote {
|
||||
dot-netty.tcp {
|
||||
hostname = ""localhost""
|
||||
port = 0
|
||||
}
|
||||
}
|
||||
cluster {
|
||||
seed-nodes = [""akka.tcp://scadabridge-test@localhost:0""]
|
||||
roles = [""Central""]
|
||||
min-nr-of-members = 1
|
||||
}
|
||||
coordinated-shutdown {
|
||||
run-by-clr-shutdown-hook = on
|
||||
}
|
||||
}";
|
||||
var config = ConfigurationFactory.ParseString(hocon);
|
||||
_actorSystem = ActorSystem.Create("scadabridge-test", config);
|
||||
|
||||
Assert.NotNull(_actorSystem);
|
||||
Assert.Equal("scadabridge-test", _actorSystem.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActorSystem_HoconConfig_IncludesCoordinatedShutdown()
|
||||
{
|
||||
var hocon = @"
|
||||
akka {
|
||||
actor {
|
||||
provider = cluster
|
||||
}
|
||||
remote {
|
||||
dot-netty.tcp {
|
||||
hostname = ""localhost""
|
||||
port = 0
|
||||
}
|
||||
}
|
||||
cluster {
|
||||
seed-nodes = [""akka.tcp://scadabridge-test@localhost:0""]
|
||||
roles = [""Central""]
|
||||
run-coordinated-shutdown-when-down = on
|
||||
}
|
||||
coordinated-shutdown {
|
||||
run-by-clr-shutdown-hook = on
|
||||
}
|
||||
}";
|
||||
var config = ConfigurationFactory.ParseString(hocon);
|
||||
_actorSystem = ActorSystem.Create("scadabridge-cs-test", config);
|
||||
|
||||
var csConfig = _actorSystem.Settings.Config.GetString("akka.coordinated-shutdown.run-by-clr-shutdown-hook");
|
||||
Assert.Equal("on", csConfig);
|
||||
|
||||
var clusterShutdown = _actorSystem.Settings.Config.GetString("akka.cluster.run-coordinated-shutdown-when-down");
|
||||
Assert.Equal("on", clusterShutdown);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
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 ZB.MOM.WW.ScadaBridge.AuditLog;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
||||
using ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
using ZB.MOM.WW.ScadaBridge.Host;
|
||||
using ZB.MOM.WW.ScadaBridge.Host.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.StoreAndForward;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.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://scadabridge@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?>
|
||||
{
|
||||
["ScadaBridge:Node:NodeHostname"] = "localhost",
|
||||
["ScadaBridge:Node:RemotingPort"] = "0",
|
||||
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
|
||||
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
|
||||
["ScadaBridge:Database:SkipMigrations"] = "true",
|
||||
["ScadaBridge:Security:JwtSigningKey"] = "test-signing-key-must-be-at-least-32-chars-long!",
|
||||
["ScadaBridge:Security:LdapServer"] = "localhost",
|
||||
["ScadaBridge:Security:LdapPort"] = "3893",
|
||||
["ScadaBridge:Security:LdapUseTls"] = "false",
|
||||
["ScadaBridge:Security:AllowInsecureLdap"] = "true",
|
||||
["ScadaBridge:Security:LdapSearchBase"] = "dc=scadabridge,dc=local",
|
||||
["ScadaBridge:InboundApi:ApiKeyPepper"] = "test-inbound-api-key-pepper-at-least-32-chars!",
|
||||
});
|
||||
});
|
||||
builder.UseSetting("ScadaBridge:Node:Role", "Central");
|
||||
builder.UseSetting("ScadaBridge:Database:SkipMigrations", "true");
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
var descriptorsToRemove = services
|
||||
.Where(d =>
|
||||
d.ServiceType == typeof(DbContextOptions<ScadaBridgeDbContext>) ||
|
||||
d.ServiceType == typeof(DbContextOptions) ||
|
||||
d.ServiceType == typeof(ScadaBridgeDbContext) ||
|
||||
d.ServiceType.FullName?.Contains("EntityFrameworkCore") == true)
|
||||
.ToList();
|
||||
foreach (var d in descriptorsToRemove)
|
||||
services.Remove(d);
|
||||
|
||||
services.AddDbContext<ScadaBridgeDbContext>(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<ZB.MOM.WW.ScadaBridge.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>
|
||||
/// M3 Bundle F (T15): the Central composition root calls
|
||||
/// <c>AddSiteCallAudit()</c>. 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
|
||||
/// <c>AkkaHostedService</c> (via the root <see cref="IServiceProvider"/>),
|
||||
/// not from a DI factory. Asserting the host built confirms the wiring
|
||||
/// call is in place; this test guards against accidentally removing it
|
||||
/// from <c>Program.cs</c>.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// M3 Bundle F: the Central composition root registers
|
||||
/// <c>ICachedCallTelemetryForwarder</c> as a lazy singleton (the
|
||||
/// forwarder degrades to audit-only emission when the site-only
|
||||
/// <c>IOperationTrackingStore</c> 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.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Central_Resolves_ICachedCallTelemetryForwarder_LazySingleton()
|
||||
{
|
||||
var forwarder = _factory.Services.GetService<ICachedCallTelemetryForwarder>();
|
||||
Assert.NotNull(forwarder);
|
||||
Assert.IsType<CachedCallTelemetryForwarder>(forwarder);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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(), $"scadabridge_audit_wiring_{Guid.NewGuid()}.db");
|
||||
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.Configuration.Sources.Clear();
|
||||
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ScadaBridge:Node:Role"] = "Site",
|
||||
["ScadaBridge:Node:NodeHostname"] = "test-site",
|
||||
["ScadaBridge:Node:SiteId"] = "TestSite",
|
||||
["ScadaBridge:Node:RemotingPort"] = "0",
|
||||
["ScadaBridge:Node:GrpcPort"] = "0",
|
||||
["ScadaBridge:Database:SiteDbPath"] = _tempDbPath,
|
||||
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
|
||||
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@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<ZB.MOM.WW.ScadaBridge.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_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<IAuditWriteFailureCounter>();
|
||||
Assert.NotNull(counter);
|
||||
Assert.IsType<HealthMetricsAuditWriteFailureCounter>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// M3 Bundle F (T15): the site composition root resolves the cached-call
|
||||
/// telemetry forwarder. ScriptExecutionActor consumes this through
|
||||
/// <c>GetService<ICachedCallTelemetryForwarder>()</c> on every script
|
||||
/// execution; a missing registration would silently degrade
|
||||
/// <c>ExternalSystem.CachedCall</c> / <c>Database.CachedWrite</c> to the
|
||||
/// "no-emission" path and break the M3 audit pipeline.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Site_Resolves_ICachedCallTelemetryForwarder()
|
||||
{
|
||||
var forwarder = _host.Services.GetService<ICachedCallTelemetryForwarder>();
|
||||
Assert.NotNull(forwarder);
|
||||
Assert.IsType<CachedCallTelemetryForwarder>(forwarder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Site_Resolves_CachedCallLifecycleBridge_AsSingleton()
|
||||
{
|
||||
var a = _host.Services.GetService<CachedCallLifecycleBridge>();
|
||||
var b = _host.Services.GetService<CachedCallLifecycleBridge>();
|
||||
Assert.NotNull(a);
|
||||
Assert.NotNull(b);
|
||||
Assert.Same(a, b);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// M3 Bundle F (T15): the lifecycle bridge is bound to the
|
||||
/// <see cref="ICachedCallLifecycleObserver"/> 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.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Site_ICachedCallLifecycleObserver_IsTheLifecycleBridge()
|
||||
{
|
||||
var observer = _host.Services.GetService<ICachedCallLifecycleObserver>();
|
||||
var bridge = _host.Services.GetService<CachedCallLifecycleBridge>();
|
||||
Assert.NotNull(observer);
|
||||
Assert.NotNull(bridge);
|
||||
Assert.Same(bridge, observer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// M3 Bundle F (T15): the Host registers an
|
||||
/// <see cref="IStoreAndForwardSiteContext"/> adapter so the S&F service
|
||||
/// can resolve the site id at composition time WITHOUT introducing a
|
||||
/// StoreAndForward → HealthMonitoring project-reference cycle.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Site_Resolves_IStoreAndForwardSiteContext_FromHost()
|
||||
{
|
||||
var ctx = _host.Services.GetService<IStoreAndForwardSiteContext>();
|
||||
Assert.NotNull(ctx);
|
||||
Assert.Equal("TestSite", ctx!.SiteId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Host-003: <c>appsettings.Central.json</c> no longer commits database connection
|
||||
/// strings — they are externalised to environment variables. Tests that exercise the
|
||||
/// full <c>Program</c> startup pipeline against the real SQL provider must therefore
|
||||
/// supply the local dev connection string the way a deployment would: via an
|
||||
/// environment variable (<c>Program</c>'s configuration builder calls
|
||||
/// <c>AddEnvironmentVariables()</c>).
|
||||
///
|
||||
/// Dispose restores the previous value so tests stay isolated.
|
||||
/// </summary>
|
||||
internal sealed class CentralDbTestEnvironment : IDisposable
|
||||
{
|
||||
// Local dev/test database — same credentials the infra docker-compose stack uses.
|
||||
// This is a test fixture value, not a committed production secret.
|
||||
private const string ConfigurationDb =
|
||||
"Server=localhost,1433;Database=ScadaBridgeConfig;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true";
|
||||
|
||||
private const string ConfigKey = "ScadaBridge__Database__ConfigurationDb";
|
||||
|
||||
private readonly string? _previousConfig;
|
||||
|
||||
public CentralDbTestEnvironment()
|
||||
{
|
||||
_previousConfig = Environment.GetEnvironmentVariable(ConfigKey);
|
||||
Environment.SetEnvironmentVariable(ConfigKey, ConfigurationDb);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Environment.SetEnvironmentVariable(ConfigKey, _previousConfig);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
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 ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer;
|
||||
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
||||
using ZB.MOM.WW.ScadaBridge.ExternalSystemGateway;
|
||||
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||
using ZB.MOM.WW.ScadaBridge.Host;
|
||||
using ZB.MOM.WW.ScadaBridge.Host.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.Host.Health;
|
||||
using ZB.MOM.WW.ScadaBridge.InboundAPI;
|
||||
using ZB.MOM.WW.ScadaBridge.ManagementService;
|
||||
using ZB.MOM.WW.ScadaBridge.NotificationService;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming;
|
||||
using ZB.MOM.WW.ScadaBridge.StoreAndForward;
|
||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Removes AkkaHostedService from running as a hosted service while keeping it
|
||||
/// resolvable in DI (other services like AkkaHealthReportTransport depend on it).
|
||||
/// </summary>
|
||||
internal static class AkkaHostedServiceRemover
|
||||
{
|
||||
internal static void RemoveAkkaHostedServiceOnly(IServiceCollection services)
|
||||
{
|
||||
// Pattern used in Program.cs:
|
||||
// services.AddSingleton<AkkaHostedService>(); // index N
|
||||
// services.AddHostedService(sp => sp.GetRequiredService<AkkaHostedService>()); // index N+1
|
||||
// We keep the singleton so DI resolution works, but remove the IHostedService
|
||||
// factory so StartAsync is never called.
|
||||
int akkaIndex = -1;
|
||||
for (int i = 0; i < services.Count; i++)
|
||||
{
|
||||
if (services[i].ServiceType == typeof(AkkaHostedService))
|
||||
{
|
||||
akkaIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (akkaIndex < 0) return;
|
||||
|
||||
// The IHostedService factory is the next registration after the singleton
|
||||
for (int i = akkaIndex + 1; i < services.Count; i++)
|
||||
{
|
||||
if (services[i].ServiceType == typeof(IHostedService)
|
||||
&& services[i].ImplementationFactory != null)
|
||||
{
|
||||
services.RemoveAt(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies every expected DI service resolves from the Central composition root.
|
||||
/// Uses WebApplicationFactory to exercise the real Program.cs pipeline.
|
||||
/// </summary>
|
||||
public class CentralCompositionRootTests : IDisposable
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
private readonly string? _previousEnv;
|
||||
|
||||
public CentralCompositionRootTests()
|
||||
{
|
||||
_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?>
|
||||
{
|
||||
["ScadaBridge:Node:NodeHostname"] = "localhost",
|
||||
["ScadaBridge:Node:RemotingPort"] = "0",
|
||||
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
|
||||
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
|
||||
["ScadaBridge:Database:SkipMigrations"] = "true",
|
||||
["ScadaBridge:Security:JwtSigningKey"] = "test-signing-key-must-be-at-least-32-chars-long!",
|
||||
["ScadaBridge:Security:LdapServer"] = "localhost",
|
||||
["ScadaBridge:Security:LdapPort"] = "3893",
|
||||
["ScadaBridge:Security:LdapUseTls"] = "false",
|
||||
["ScadaBridge:Security:AllowInsecureLdap"] = "true",
|
||||
["ScadaBridge:Security:LdapSearchBase"] = "dc=scadabridge,dc=local",
|
||||
// ConfigurationDatabase-012: inbound-API keys are hashed
|
||||
// with a server-side HMAC pepper; ApiKeyHasher fails fast
|
||||
// if it is missing or weak, so resolving ApiKeyValidator
|
||||
// requires a configured pepper.
|
||||
["ScadaBridge:InboundApi:ApiKeyPepper"] = "test-inbound-api-key-pepper-at-least-32-chars!",
|
||||
});
|
||||
});
|
||||
builder.UseSetting("ScadaBridge:Node:Role", "Central");
|
||||
builder.UseSetting("ScadaBridge:Database:SkipMigrations", "true");
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Replace SQL Server with in-memory database
|
||||
var descriptorsToRemove = services
|
||||
.Where(d =>
|
||||
d.ServiceType == typeof(DbContextOptions<ScadaBridgeDbContext>) ||
|
||||
d.ServiceType == typeof(DbContextOptions) ||
|
||||
d.ServiceType == typeof(ScadaBridgeDbContext) ||
|
||||
d.ServiceType.FullName?.Contains("EntityFrameworkCore") == true)
|
||||
.ToList();
|
||||
foreach (var d in descriptorsToRemove)
|
||||
services.Remove(d);
|
||||
|
||||
services.AddDbContext<ScadaBridgeDbContext>(options =>
|
||||
options.UseInMemoryDatabase($"CompositionRootTests_{Guid.NewGuid()}"));
|
||||
|
||||
// Keep AkkaHostedService in DI (other services depend on it)
|
||||
// but prevent it from starting by removing only its IHostedService registration.
|
||||
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(services);
|
||||
});
|
||||
});
|
||||
|
||||
// Trigger host build
|
||||
_ = _factory.Server;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_factory.Dispose();
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", _previousEnv);
|
||||
}
|
||||
|
||||
// --- Singletons ---
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CentralSingletonServices))]
|
||||
public void Central_ResolveSingleton(Type serviceType)
|
||||
{
|
||||
var service = _factory.Services.GetService(serviceType);
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> CentralSingletonServices => new[]
|
||||
{
|
||||
new object[] { typeof(CommunicationService) },
|
||||
new object[] { typeof(ISiteHealthCollector) },
|
||||
new object[] { typeof(CentralHealthAggregator) },
|
||||
new object[] { typeof(ICentralHealthAggregator) },
|
||||
new object[] { typeof(OperationLockManager) },
|
||||
new object[] { typeof(OAuth2TokenService) },
|
||||
new object[] { typeof(InboundScriptExecutor) },
|
||||
};
|
||||
|
||||
// --- Scoped services ---
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CentralScopedServices))]
|
||||
public void Central_ResolveScoped(Type serviceType)
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var service = scope.ServiceProvider.GetService(serviceType);
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> CentralScopedServices => new[]
|
||||
{
|
||||
// TemplateEngine
|
||||
new object[] { typeof(TemplateService) },
|
||||
new object[] { typeof(SharedScriptService) },
|
||||
new object[] { typeof(InstanceService) },
|
||||
new object[] { typeof(SiteService) },
|
||||
new object[] { typeof(AreaService) },
|
||||
new object[] { typeof(TemplateDeletionService) },
|
||||
// DeploymentManager
|
||||
new object[] { typeof(IFlatteningPipeline) },
|
||||
new object[] { typeof(DeploymentService) },
|
||||
new object[] { typeof(ArtifactDeploymentService) },
|
||||
// Security
|
||||
new object[] { typeof(LdapAuthService) },
|
||||
new object[] { typeof(JwtTokenService) },
|
||||
new object[] { typeof(RoleMapper) },
|
||||
// InboundAPI
|
||||
new object[] { typeof(ApiKeyValidator) },
|
||||
new object[] { typeof(RouteHelper) },
|
||||
// ExternalSystemGateway
|
||||
new object[] { typeof(ExternalSystemClient) },
|
||||
new object[] { typeof(IExternalSystemClient) },
|
||||
new object[] { typeof(DatabaseGateway) },
|
||||
new object[] { typeof(IDatabaseGateway) },
|
||||
// NotificationService — central-only SMTP primitives. The orphan
|
||||
// NotificationDeliveryService + INotificationDeliveryService were removed
|
||||
// (NS-019) when sites stopped delivering notifications; the central
|
||||
// EmailNotificationDeliveryAdapter is now the only resolver of these
|
||||
// primitives.
|
||||
new object[] { typeof(Func<ISmtpClientWrapper>) },
|
||||
new object[] { typeof(OAuth2TokenService) },
|
||||
// ConfigurationDatabase repositories
|
||||
new object[] { typeof(ScadaBridgeDbContext) },
|
||||
new object[] { typeof(ISecurityRepository) },
|
||||
new object[] { typeof(ICentralUiRepository) },
|
||||
new object[] { typeof(ITemplateEngineRepository) },
|
||||
new object[] { typeof(IDeploymentManagerRepository) },
|
||||
new object[] { typeof(ISiteRepository) },
|
||||
new object[] { typeof(IExternalSystemRepository) },
|
||||
new object[] { typeof(INotificationRepository) },
|
||||
new object[] { typeof(IInboundApiRepository) },
|
||||
new object[] { typeof(IAuditService) },
|
||||
new object[] { typeof(IInstanceLocator) },
|
||||
// CentralUI
|
||||
new object[] { typeof(AuthenticationStateProvider) },
|
||||
};
|
||||
|
||||
// --- Transient services ---
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CentralTransientServices))]
|
||||
public void Central_ResolveTransient(Type serviceType)
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var service = scope.ServiceProvider.GetService(serviceType);
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> CentralTransientServices => new[]
|
||||
{
|
||||
new object[] { typeof(FlatteningService) },
|
||||
new object[] { typeof(DiffService) },
|
||||
new object[] { typeof(RevisionHashService) },
|
||||
new object[] { typeof(ScriptCompiler) },
|
||||
new object[] { typeof(SemanticValidator) },
|
||||
new object[] { typeof(ValidationService) },
|
||||
};
|
||||
|
||||
// --- Options ---
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CentralOptions))]
|
||||
public void Central_ResolveOptions(Type optionsType)
|
||||
{
|
||||
var service = _factory.Services.GetService(optionsType);
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> CentralOptions => new[]
|
||||
{
|
||||
new object[] { typeof(IOptions<NodeOptions>) },
|
||||
new object[] { typeof(IOptions<ClusterOptions>) },
|
||||
new object[] { typeof(IOptions<DatabaseOptions>) },
|
||||
new object[] { typeof(IOptions<CommunicationOptions>) },
|
||||
new object[] { typeof(IOptions<HealthMonitoringOptions>) },
|
||||
new object[] { typeof(IOptions<NotificationOptions>) },
|
||||
new object[] { typeof(IOptions<LoggingOptions>) },
|
||||
new object[] { typeof(IOptions<SecurityOptions>) },
|
||||
new object[] { typeof(IOptions<InboundApiOptions>) },
|
||||
new object[] { typeof(IOptions<ManagementServiceOptions>) },
|
||||
new object[] { typeof(IOptions<ExternalSystemGatewayOptions>) },
|
||||
};
|
||||
|
||||
// --- Hosted services ---
|
||||
|
||||
[Fact]
|
||||
public void Central_CentralHealthAggregator_RegisteredAsHostedService()
|
||||
{
|
||||
var hostedServices = _factory.Services.GetServices<IHostedService>();
|
||||
Assert.Contains(hostedServices, s => s.GetType() == typeof(CentralHealthAggregator));
|
||||
}
|
||||
|
||||
// --- InboundAPI-022 regression ---
|
||||
|
||||
/// <summary>
|
||||
/// InboundAPI-022 regression: the Central composition root MUST register a
|
||||
/// concrete <see cref="IActiveNodeGate"/> implementation. Without it,
|
||||
/// <see cref="InboundApiEndpointFilter"/> falls through to "allow" and a
|
||||
/// standby central node continues to serve the inbound API, racing the
|
||||
/// active node and executing scripts against stale singleton state. The
|
||||
/// design's "central cluster only (active node)" guarantee is enforced only
|
||||
/// when the production gate is wired here.
|
||||
///
|
||||
/// Structural check on the built provider (not just <see cref="IServiceCollection"/>)
|
||||
/// — a registration the framework cannot resolve to a concrete instance is
|
||||
/// indistinguishable from "missing" at runtime, which is the failure mode
|
||||
/// the finding describes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Central_IActiveNodeGate_IsRegisteredAsActiveNodeGate()
|
||||
{
|
||||
var gate = _factory.Services.GetService<IActiveNodeGate>();
|
||||
Assert.NotNull(gate);
|
||||
Assert.IsType<ActiveNodeGate>(gate);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies every expected DI service resolves from the Site composition root.
|
||||
/// Uses the extracted SiteServiceRegistration.Configure() so the test always
|
||||
/// matches the real Program.cs registration (WebApplicationBuilder + gRPC).
|
||||
/// </summary>
|
||||
public class SiteCompositionRootTests : IDisposable
|
||||
{
|
||||
private readonly WebApplication _host;
|
||||
private readonly string _tempDbPath;
|
||||
|
||||
public SiteCompositionRootTests()
|
||||
{
|
||||
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_test_{Guid.NewGuid()}.db");
|
||||
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.Configuration.Sources.Clear();
|
||||
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ScadaBridge:Node:Role"] = "Site",
|
||||
["ScadaBridge:Node:NodeHostname"] = "test-site",
|
||||
["ScadaBridge:Node:SiteId"] = "TestSite",
|
||||
["ScadaBridge:Node:RemotingPort"] = "0",
|
||||
["ScadaBridge:Node:GrpcPort"] = "0",
|
||||
["ScadaBridge:Database:SiteDbPath"] = _tempDbPath,
|
||||
// ClusterOptions requires at least one seed node (ClusterOptionsValidator).
|
||||
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
|
||||
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
|
||||
});
|
||||
|
||||
// gRPC server registration (mirrors Program.cs site section)
|
||||
builder.Services.AddGrpc();
|
||||
builder.Services.AddSingleton<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer>();
|
||||
|
||||
SiteServiceRegistration.Configure(builder.Services, builder.Configuration);
|
||||
|
||||
// Keep AkkaHostedService in DI (other services depend on it)
|
||||
// but prevent it from starting by removing only its IHostedService registration.
|
||||
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(builder.Services);
|
||||
|
||||
_host = builder.Build();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
(_host as IDisposable)?.Dispose();
|
||||
try { File.Delete(_tempDbPath); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
// --- Singletons ---
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(SiteSingletonServices))]
|
||||
public void Site_ResolveSingleton(Type serviceType)
|
||||
{
|
||||
var service = _host.Services.GetService(serviceType);
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> SiteSingletonServices => new[]
|
||||
{
|
||||
new object[] { typeof(CommunicationService) },
|
||||
new object[] { typeof(ISiteHealthCollector) },
|
||||
new object[] { typeof(SiteStorageService) },
|
||||
new object[] { typeof(ScriptCompilationService) },
|
||||
new object[] { typeof(SharedScriptLibrary) },
|
||||
new object[] { typeof(SiteStreamManager) },
|
||||
new object[] { typeof(ISiteStreamSubscriber) },
|
||||
new object[] { typeof(SiteStreamGrpcServer) },
|
||||
new object[] { typeof(IDataConnectionFactory) },
|
||||
new object[] { typeof(StoreAndForwardStorage) },
|
||||
new object[] { typeof(StoreAndForwardService) },
|
||||
new object[] { typeof(ReplicationService) },
|
||||
new object[] { typeof(ISiteEventLogger) },
|
||||
new object[] { typeof(IEventLogQueryService) },
|
||||
new object[] { typeof(ISiteIdentityProvider) },
|
||||
new object[] { typeof(IHealthReportTransport) },
|
||||
};
|
||||
|
||||
// --- Scoped services ---
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(SiteScopedServices))]
|
||||
public void Site_ResolveScoped(Type serviceType)
|
||||
{
|
||||
using var scope = _host.Services.CreateScope();
|
||||
var service = scope.ServiceProvider.GetService(serviceType);
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> SiteScopedServices => new[]
|
||||
{
|
||||
new object[] { typeof(IExternalSystemRepository) },
|
||||
new object[] { typeof(INotificationRepository) },
|
||||
new object[] { typeof(ExternalSystemClient) },
|
||||
new object[] { typeof(IExternalSystemClient) },
|
||||
new object[] { typeof(DatabaseGateway) },
|
||||
new object[] { typeof(IDatabaseGateway) },
|
||||
};
|
||||
|
||||
// --- Implementation type assertions ---
|
||||
|
||||
[Fact]
|
||||
public void Site_ExternalSystemRepository_IsSiteImplementation()
|
||||
{
|
||||
using var scope = _host.Services.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<IExternalSystemRepository>();
|
||||
Assert.IsType<SiteExternalSystemRepository>(repo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Site_NotificationRepository_IsSiteImplementation()
|
||||
{
|
||||
using var scope = _host.Services.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<INotificationRepository>();
|
||||
Assert.IsType<SiteNotificationRepository>(repo);
|
||||
}
|
||||
|
||||
// --- Options ---
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(SiteOptions))]
|
||||
public void Site_ResolveOptions(Type optionsType)
|
||||
{
|
||||
var service = _host.Services.GetService(optionsType);
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> SiteOptions => new[]
|
||||
{
|
||||
new object[] { typeof(IOptions<NodeOptions>) },
|
||||
new object[] { typeof(IOptions<ClusterOptions>) },
|
||||
new object[] { typeof(IOptions<DatabaseOptions>) },
|
||||
new object[] { typeof(IOptions<CommunicationOptions>) },
|
||||
new object[] { typeof(IOptions<HealthMonitoringOptions>) },
|
||||
new object[] { typeof(IOptions<NotificationOptions>) },
|
||||
new object[] { typeof(IOptions<LoggingOptions>) },
|
||||
new object[] { typeof(IOptions<SiteRuntimeOptions>) },
|
||||
new object[] { typeof(IOptions<DataConnectionOptions>) },
|
||||
new object[] { typeof(IOptions<StoreAndForwardOptions>) },
|
||||
new object[] { typeof(IOptions<SiteEventLogOptions>) },
|
||||
};
|
||||
|
||||
// --- Hosted services ---
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(SiteHostedServices))]
|
||||
public void Site_HostedServiceRegistered(Type expectedType)
|
||||
{
|
||||
var hostedServices = _host.Services.GetServices<IHostedService>();
|
||||
Assert.Contains(hostedServices, s => s.GetType() == expectedType);
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> SiteHostedServices => new[]
|
||||
{
|
||||
new object[] { typeof(HealthReportSender) },
|
||||
new object[] { typeof(SiteStorageInitializer) },
|
||||
new object[] { typeof(EventLogPurgeService) },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Host-003 regression: secrets must not be committed in plaintext in the
|
||||
/// shipped <c>appsettings.Central.json</c>. Connection-string passwords, the LDAP
|
||||
/// service-account password and the JWT signing key must be supplied via
|
||||
/// environment variables (or another secret store) at deployment time — the
|
||||
/// committed file may only carry non-sensitive structural defaults or
|
||||
/// placeholder values.
|
||||
/// </summary>
|
||||
public class ConfigSecretsTests
|
||||
{
|
||||
private static string FindHostProjectDirectory()
|
||||
{
|
||||
var assemblyDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;
|
||||
var dir = new DirectoryInfo(assemblyDir);
|
||||
while (dir != null)
|
||||
{
|
||||
var hostPath = Path.Combine(dir.FullName, "src", "ZB.MOM.WW.ScadaBridge.Host");
|
||||
if (Directory.Exists(hostPath))
|
||||
return hostPath;
|
||||
dir = dir.Parent;
|
||||
}
|
||||
throw new DirectoryNotFoundException("Could not locate src/ZB.MOM.WW.ScadaBridge.Host");
|
||||
}
|
||||
|
||||
private static JsonElement ScadaBridgeSection()
|
||||
{
|
||||
var path = Path.Combine(FindHostProjectDirectory(), "appsettings.Central.json");
|
||||
var json = File.ReadAllText(path);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return doc.RootElement.GetProperty("ScadaBridge").Clone();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CentralConfig_ConnectionStrings_ContainNoPlaintextPassword()
|
||||
{
|
||||
var db = ScadaBridgeSection().GetProperty("Database");
|
||||
foreach (var prop in db.EnumerateObject())
|
||||
{
|
||||
var value = prop.Value.GetString() ?? string.Empty;
|
||||
// A committed connection string must not carry a literal Password= value.
|
||||
// Either the password is delivered via an environment variable or the
|
||||
// whole connection string is. A placeholder reference is acceptable.
|
||||
var idx = value.IndexOf("Password=", StringComparison.OrdinalIgnoreCase);
|
||||
if (idx >= 0)
|
||||
{
|
||||
var after = value[(idx + "Password=".Length)..];
|
||||
var literal = after.Split(';')[0];
|
||||
Assert.True(
|
||||
literal.Length == 0 || literal.Contains('{') || literal.Contains('$'),
|
||||
$"appsettings.Central.json '{prop.Name}' contains a plaintext Password value '{literal}'. " +
|
||||
"Move the secret to an environment variable.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CentralConfig_LdapServiceAccountPassword_IsNotCommitted()
|
||||
{
|
||||
var security = ScadaBridgeSection().GetProperty("Security");
|
||||
if (security.TryGetProperty("LdapServiceAccountPassword", out var pw))
|
||||
{
|
||||
var value = pw.GetString() ?? string.Empty;
|
||||
Assert.True(
|
||||
value.Length == 0 || value.Contains('{') || value.Contains('$'),
|
||||
$"appsettings.Central.json carries a plaintext LdapServiceAccountPassword '{value}'. " +
|
||||
"Move it to an environment variable.");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CentralConfig_JwtSigningKey_IsNotCommitted()
|
||||
{
|
||||
var security = ScadaBridgeSection().GetProperty("Security");
|
||||
if (security.TryGetProperty("JwtSigningKey", out var key))
|
||||
{
|
||||
var value = key.GetString() ?? string.Empty;
|
||||
Assert.True(
|
||||
value.Length == 0 || value.Contains('{') || value.Contains('$'),
|
||||
$"appsettings.Central.json carries a committed JwtSigningKey '{value}'. " +
|
||||
"A committed signing key lets anyone with repo access forge session tokens. " +
|
||||
"Move it to an environment variable.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-16: Tests for CoordinatedShutdown configuration.
|
||||
/// Verifies no Environment.Exit calls exist in source and HOCON config is correct.
|
||||
/// </summary>
|
||||
public class CoordinatedShutdownTests
|
||||
{
|
||||
[Fact]
|
||||
public void HostSource_DoesNotContainEnvironmentExit()
|
||||
{
|
||||
var hostProjectDir = FindHostProjectDirectory();
|
||||
Assert.NotNull(hostProjectDir);
|
||||
|
||||
var sourceFiles = Directory.GetFiles(hostProjectDir, "*.cs", SearchOption.AllDirectories);
|
||||
Assert.NotEmpty(sourceFiles);
|
||||
|
||||
foreach (var file in sourceFiles)
|
||||
{
|
||||
var content = File.ReadAllText(file);
|
||||
Assert.DoesNotContain("Environment.Exit", content,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AkkaHostedService_HoconConfig_IncludesCoordinatedShutdownSettings()
|
||||
{
|
||||
// Read the AkkaHostedService source to verify HOCON configuration
|
||||
var hostProjectDir = FindHostProjectDirectory();
|
||||
Assert.NotNull(hostProjectDir);
|
||||
|
||||
var akkaServiceFile = Path.Combine(hostProjectDir, "Actors", "AkkaHostedService.cs");
|
||||
Assert.True(File.Exists(akkaServiceFile), $"AkkaHostedService.cs not found at {akkaServiceFile}");
|
||||
|
||||
var content = File.ReadAllText(akkaServiceFile);
|
||||
|
||||
// Verify critical HOCON settings are present
|
||||
Assert.Contains("run-by-clr-shutdown-hook = on", content);
|
||||
Assert.Contains("run-coordinated-shutdown-when-down = on", content);
|
||||
}
|
||||
|
||||
private static string? FindHostProjectDirectory()
|
||||
{
|
||||
var assemblyDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;
|
||||
var dir = new DirectoryInfo(assemblyDir);
|
||||
|
||||
while (dir != null)
|
||||
{
|
||||
var hostPath = Path.Combine(dir.FullName, "src", "ZB.MOM.WW.ScadaBridge.Host");
|
||||
if (Directory.Exists(hostPath))
|
||||
return hostPath;
|
||||
dir = dir.Parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Event;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Host.Actors;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-15: Tests for DeadLetterMonitorActor.
|
||||
/// </summary>
|
||||
public class DeadLetterMonitorTests : TestKit
|
||||
{
|
||||
private readonly ILogger<DeadLetterMonitorActor> _logger =
|
||||
NullLoggerFactory.Instance.CreateLogger<DeadLetterMonitorActor>();
|
||||
|
||||
[Fact]
|
||||
public void DeadLetterMonitor_StartsWithZeroCount()
|
||||
{
|
||||
var monitor = Sys.ActorOf(Props.Create(() => new DeadLetterMonitorActor(_logger)));
|
||||
|
||||
monitor.Tell(GetDeadLetterCount.Instance);
|
||||
var response = ExpectMsg<DeadLetterCountResponse>();
|
||||
|
||||
Assert.Equal(0, response.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeadLetterMonitor_IncrementsOnDeadLetter()
|
||||
{
|
||||
var monitor = Sys.ActorOf(Props.Create(() => new DeadLetterMonitorActor(_logger)));
|
||||
|
||||
// Ensure actor has started and subscribed by sending a message and waiting for response
|
||||
monitor.Tell(GetDeadLetterCount.Instance);
|
||||
ExpectMsg<DeadLetterCountResponse>();
|
||||
|
||||
// Now publish dead letters — actor is guaranteed to be subscribed
|
||||
Sys.EventStream.Publish(new DeadLetter("test-message-1", Sys.DeadLetters, Sys.DeadLetters));
|
||||
Sys.EventStream.Publish(new DeadLetter("test-message-2", Sys.DeadLetters, Sys.DeadLetters));
|
||||
|
||||
// Use AwaitAssert to handle async event delivery
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
monitor.Tell(GetDeadLetterCount.Instance);
|
||||
var response = ExpectMsg<DeadLetterCountResponse>();
|
||||
Assert.Equal(2, response.Count);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeadLetterMonitor_CountAccumulates()
|
||||
{
|
||||
var monitor = Sys.ActorOf(Props.Create(() => new DeadLetterMonitorActor(_logger)));
|
||||
|
||||
// Ensure actor is started and subscribed
|
||||
monitor.Tell(GetDeadLetterCount.Instance);
|
||||
ExpectMsg<DeadLetterCountResponse>();
|
||||
|
||||
// Send 5 dead letters
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
Sys.EventStream.Publish(
|
||||
new DeadLetter($"message-{i}", Sys.DeadLetters, Sys.DeadLetters));
|
||||
}
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
monitor.Tell(GetDeadLetterCount.Instance);
|
||||
var response = ExpectMsg<DeadLetterCountResponse>();
|
||||
Assert.Equal(5, response.Count);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using ZB.MOM.WW.ScadaBridge.Host.Health;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-12: Tests for /health/ready and /health/active endpoints.
|
||||
/// </summary>
|
||||
public class HealthCheckTests : IDisposable
|
||||
{
|
||||
private readonly List<IDisposable> _disposables = new();
|
||||
|
||||
public HealthCheckTests()
|
||||
{
|
||||
// Host-003: connection strings are externalised; supply them via env vars.
|
||||
_disposables.Add(new CentralDbTestEnvironment());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var d in _disposables)
|
||||
{
|
||||
try { d.Dispose(); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthReady_Endpoint_ReturnsResponse()
|
||||
{
|
||||
var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
|
||||
|
||||
var factory = new WebApplicationFactory<Program>()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((context, config) =>
|
||||
{
|
||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ScadaBridge:Node:NodeHostname"] = "localhost",
|
||||
["ScadaBridge:Node:RemotingPort"] = "0",
|
||||
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
|
||||
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
|
||||
["ScadaBridge:Database:SkipMigrations"] = "true",
|
||||
});
|
||||
});
|
||||
builder.UseSetting("ScadaBridge:Node:Role", "Central");
|
||||
builder.UseSetting("ScadaBridge:Database:SkipMigrations", "true");
|
||||
});
|
||||
_disposables.Add(factory);
|
||||
|
||||
var client = factory.CreateClient();
|
||||
_disposables.Add(client);
|
||||
|
||||
var response = await client.GetAsync("/health/ready");
|
||||
|
||||
// The endpoint exists and returns a status code.
|
||||
// With test infrastructure (no real DB), the database check may fail,
|
||||
// so we accept either 200 (Healthy) or 503 (Unhealthy).
|
||||
Assert.True(
|
||||
response.StatusCode == System.Net.HttpStatusCode.OK ||
|
||||
response.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable,
|
||||
$"Expected 200 or 503, got {(int)response.StatusCode}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", previousEnv);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthActive_Endpoint_ReturnsResponse()
|
||||
{
|
||||
var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
|
||||
|
||||
var factory = new WebApplicationFactory<Program>()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((context, config) =>
|
||||
{
|
||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ScadaBridge:Node:NodeHostname"] = "localhost",
|
||||
["ScadaBridge:Node:RemotingPort"] = "0",
|
||||
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
|
||||
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
|
||||
["ScadaBridge:Database:SkipMigrations"] = "true",
|
||||
});
|
||||
});
|
||||
builder.UseSetting("ScadaBridge:Node:Role", "Central");
|
||||
builder.UseSetting("ScadaBridge:Database:SkipMigrations", "true");
|
||||
});
|
||||
_disposables.Add(factory);
|
||||
|
||||
var client = factory.CreateClient();
|
||||
_disposables.Add(client);
|
||||
|
||||
var response = await client.GetAsync("/health/active");
|
||||
|
||||
// In test mode, the ActorSystem may not be fully available,
|
||||
// so the active-node check returns 503 (Unhealthy).
|
||||
Assert.True(
|
||||
response.StatusCode == System.Net.HttpStatusCode.OK ||
|
||||
response.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable,
|
||||
$"Expected 200 or 503, got {(int)response.StatusCode}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", previousEnv);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthReady_Endpoint_ExcludesActiveNodeCheck()
|
||||
{
|
||||
// Host-001 regression: /health/ready must reflect cluster membership + DB
|
||||
// connectivity only (REQ-HOST-4a), NOT cluster leadership. The leader-only
|
||||
// "active-node" check belongs solely to /health/active. If /health/ready
|
||||
// included "active-node", a fully operational standby central node would
|
||||
// permanently report 503, breaking load-balancer failover readiness.
|
||||
var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
|
||||
|
||||
var factory = new WebApplicationFactory<Program>()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((context, config) =>
|
||||
{
|
||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ScadaBridge:Node:NodeHostname"] = "localhost",
|
||||
["ScadaBridge:Node:RemotingPort"] = "0",
|
||||
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
|
||||
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
|
||||
["ScadaBridge:Database:SkipMigrations"] = "true",
|
||||
});
|
||||
});
|
||||
builder.UseSetting("ScadaBridge:Node:Role", "Central");
|
||||
builder.UseSetting("ScadaBridge:Database:SkipMigrations", "true");
|
||||
});
|
||||
_disposables.Add(factory);
|
||||
|
||||
var client = factory.CreateClient();
|
||||
_disposables.Add(client);
|
||||
|
||||
var response = await client.GetAsync("/health/ready");
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// The readiness body lists each executed check by name in its entries map.
|
||||
// The leader-only "active-node" check must not be among them.
|
||||
Assert.DoesNotContain("active-node", body);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", previousEnv);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActiveNodeHealthCheck_SystemNotStarted_ReturnsUnhealthy()
|
||||
{
|
||||
// AkkaHostedService before StartAsync has ActorSystem == null.
|
||||
// The integration test (HealthActive_Endpoint_ReturnsResponse) validates the full
|
||||
// endpoint wiring. This test validates the null-system path via WebApplicationFactory
|
||||
// where the ActorSystem may not be available.
|
||||
var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
|
||||
var factory = new WebApplicationFactory<Program>()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((context, config) =>
|
||||
{
|
||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ScadaBridge:Node:NodeHostname"] = "localhost",
|
||||
["ScadaBridge:Node:RemotingPort"] = "0",
|
||||
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
|
||||
["ScadaBridge:Database:SkipMigrations"] = "true",
|
||||
});
|
||||
});
|
||||
builder.UseSetting("ScadaBridge:Node:Role", "Central");
|
||||
builder.UseSetting("ScadaBridge:Database:SkipMigrations", "true");
|
||||
});
|
||||
_disposables.Add(factory);
|
||||
|
||||
var client = factory.CreateClient();
|
||||
_disposables.Add(client);
|
||||
|
||||
var response = await client.GetAsync("/health/active");
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Active-node check returns 503 when ActorSystem is not yet available or not leader
|
||||
Assert.Equal(System.Net.HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
||||
Assert.Contains("active-node", body);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", previousEnv);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using Akka.Configuration;
|
||||
using ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
|
||||
using ZB.MOM.WW.ScadaBridge.Host.Actors;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Host-006: the Akka HOCON document must be assembled with every interpolated value
|
||||
/// quoted/escaped, so a hostname, seed-node URI or split-brain strategy containing a
|
||||
/// quote, backslash or whitespace cannot corrupt the document or be silently
|
||||
/// misparsed.
|
||||
/// </summary>
|
||||
public class HoconBuilderTests
|
||||
{
|
||||
private static ClusterOptions DefaultCluster() => new()
|
||||
{
|
||||
SeedNodes = new List<string>
|
||||
{
|
||||
"akka.tcp://scadabridge@localhost:8081",
|
||||
"akka.tcp://scadabridge@localhost:8082",
|
||||
},
|
||||
SplitBrainResolverStrategy = "keep-oldest",
|
||||
MinNrOfMembers = 1,
|
||||
StableAfter = TimeSpan.FromSeconds(15),
|
||||
HeartbeatInterval = TimeSpan.FromSeconds(2),
|
||||
FailureDetectionThreshold = TimeSpan.FromSeconds(10),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void BuildHocon_PlainValues_ParsesAndPreservesValues()
|
||||
{
|
||||
var node = new NodeOptions
|
||||
{
|
||||
Role = "Central",
|
||||
NodeHostname = "central-node1",
|
||||
RemotingPort = 8081,
|
||||
};
|
||||
|
||||
var hocon = AkkaHostedService.BuildHocon(
|
||||
node, DefaultCluster(), new[] { "Central" },
|
||||
TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15));
|
||||
|
||||
var config = ConfigurationFactory.ParseString(hocon);
|
||||
Assert.Equal("central-node1", config.GetString("akka.remote.dot-netty.tcp.hostname"));
|
||||
Assert.Equal("keep-oldest", config.GetString("akka.cluster.split-brain-resolver.active-strategy"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildHocon_HostnameWithQuote_DoesNotCorruptDocument()
|
||||
{
|
||||
// A hostname containing a double quote would, with raw interpolation, close
|
||||
// the HOCON string literal early and corrupt every following key.
|
||||
var node = new NodeOptions
|
||||
{
|
||||
Role = "Central",
|
||||
NodeHostname = "evil\"host",
|
||||
RemotingPort = 8081,
|
||||
};
|
||||
|
||||
var hocon = AkkaHostedService.BuildHocon(
|
||||
node, DefaultCluster(), new[] { "Central" },
|
||||
TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15));
|
||||
|
||||
// Must still parse, and the keys after the hostname must remain intact.
|
||||
var config = ConfigurationFactory.ParseString(hocon);
|
||||
Assert.Equal("evil\"host", config.GetString("akka.remote.dot-netty.tcp.hostname"));
|
||||
Assert.Equal(8081, config.GetInt("akka.remote.dot-netty.tcp.port"));
|
||||
Assert.Equal(1, config.GetInt("akka.cluster.min-nr-of-members"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildHocon_StrategyWithWhitespace_RemainsQuoted()
|
||||
{
|
||||
var cluster = DefaultCluster();
|
||||
cluster.SplitBrainResolverStrategy = "keep oldest";
|
||||
var node = new NodeOptions
|
||||
{
|
||||
Role = "Central",
|
||||
NodeHostname = "node1",
|
||||
RemotingPort = 8081,
|
||||
};
|
||||
|
||||
var hocon = AkkaHostedService.BuildHocon(
|
||||
node, cluster, new[] { "Central" },
|
||||
TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15));
|
||||
|
||||
var config = ConfigurationFactory.ParseString(hocon);
|
||||
Assert.Equal("keep oldest", config.GetString("akka.cluster.split-brain-resolver.active-strategy"));
|
||||
}
|
||||
|
||||
private static NodeOptions DefaultNode() => new()
|
||||
{
|
||||
Role = "Central",
|
||||
NodeHostname = "node1",
|
||||
RemotingPort = 8081,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void BuildHocon_DownIfAloneTrue_EmitsOn()
|
||||
{
|
||||
// Host-012: BuildHocon must consume ClusterOptions.DownIfAlone, not
|
||||
// hard-code the down-if-alone token.
|
||||
var cluster = DefaultCluster();
|
||||
cluster.DownIfAlone = true;
|
||||
|
||||
var hocon = AkkaHostedService.BuildHocon(
|
||||
DefaultNode(), cluster, new[] { "Central" },
|
||||
TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15));
|
||||
|
||||
var config = ConfigurationFactory.ParseString(hocon);
|
||||
Assert.True(config.GetBoolean(
|
||||
"akka.cluster.split-brain-resolver.keep-oldest.down-if-alone"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildHocon_DownIfAloneFalse_EmitsOff()
|
||||
{
|
||||
// Host-012: setting DownIfAlone=false must actually flip the emitted token.
|
||||
var cluster = DefaultCluster();
|
||||
cluster.DownIfAlone = false;
|
||||
|
||||
var hocon = AkkaHostedService.BuildHocon(
|
||||
DefaultNode(), cluster, new[] { "Central" },
|
||||
TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15));
|
||||
|
||||
var config = ConfigurationFactory.ParseString(hocon);
|
||||
Assert.False(config.GetBoolean(
|
||||
"akka.cluster.split-brain-resolver.keep-oldest.down-if-alone"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildHocon_SubSecondTimings_PreservedWithoutRounding()
|
||||
{
|
||||
// Host-013: cluster timing values below one second must not be rounded to
|
||||
// whole seconds. A 750ms heartbeat must survive as 750ms, not collapse to
|
||||
// 1s — and a 500ms value must not collapse to a degenerate 0s.
|
||||
var cluster = DefaultCluster();
|
||||
cluster.HeartbeatInterval = TimeSpan.FromMilliseconds(750);
|
||||
cluster.FailureDetectionThreshold = TimeSpan.FromMilliseconds(2600);
|
||||
cluster.StableAfter = TimeSpan.FromMilliseconds(500);
|
||||
|
||||
var hocon = AkkaHostedService.BuildHocon(
|
||||
DefaultNode(), cluster, new[] { "Central" },
|
||||
TimeSpan.FromMilliseconds(2500), TimeSpan.FromMilliseconds(7500));
|
||||
|
||||
var config = ConfigurationFactory.ParseString(hocon);
|
||||
Assert.Equal(
|
||||
TimeSpan.FromMilliseconds(750),
|
||||
config.GetTimeSpan("akka.cluster.failure-detector.heartbeat-interval"));
|
||||
Assert.Equal(
|
||||
TimeSpan.FromMilliseconds(2600),
|
||||
config.GetTimeSpan("akka.cluster.failure-detector.acceptable-heartbeat-pause"));
|
||||
Assert.Equal(
|
||||
TimeSpan.FromMilliseconds(500),
|
||||
config.GetTimeSpan("akka.cluster.split-brain-resolver.stable-after"));
|
||||
Assert.Equal(
|
||||
TimeSpan.FromMilliseconds(2500),
|
||||
config.GetTimeSpan("akka.remote.transport-failure-detector.heartbeat-interval"));
|
||||
Assert.Equal(
|
||||
TimeSpan.FromMilliseconds(7500),
|
||||
config.GetTimeSpan("akka.remote.transport-failure-detector.acceptable-heartbeat-pause"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using ZB.MOM.WW.ScadaBridge.Host;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
public class HostStartupTests : IDisposable
|
||||
{
|
||||
private readonly List<IDisposable> _disposables = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var d in _disposables)
|
||||
{
|
||||
try { d.Dispose(); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CentralRole_StartsWithoutError()
|
||||
{
|
||||
// WebApplicationFactory replays Program.Main, which reads config from files.
|
||||
// Set the environment to Central so appsettings.Central.json is loaded,
|
||||
// and set DOTNET_ENVIRONMENT before the factory creates the host.
|
||||
var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
||||
// Host-003: connection strings are externalised; supply them via env vars.
|
||||
using var dbEnv = new CentralDbTestEnvironment();
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
|
||||
|
||||
var factory = new WebApplicationFactory<Program>()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, config) =>
|
||||
{
|
||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ScadaBridge:Node:NodeHostname"] = "localhost",
|
||||
["ScadaBridge:Node:RemotingPort"] = "0",
|
||||
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
|
||||
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
|
||||
["ScadaBridge:Database:SkipMigrations"] = "true",
|
||||
});
|
||||
});
|
||||
builder.UseSetting("ScadaBridge:Node:Role", "Central");
|
||||
builder.UseSetting("ScadaBridge:Database:SkipMigrations", "true");
|
||||
});
|
||||
_disposables.Add(factory);
|
||||
|
||||
// Creating the server exercises the full DI container build and startup pipeline
|
||||
var client = factory.CreateClient();
|
||||
_disposables.Add(client);
|
||||
|
||||
// If we get here without exception, the central host started successfully
|
||||
Assert.NotNull(client);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", previousEnv);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteRole_StartsWithoutError()
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.Configuration.Sources.Clear();
|
||||
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ScadaBridge:Node:Role"] = "Site",
|
||||
["ScadaBridge:Node:NodeHostname"] = "test-site",
|
||||
["ScadaBridge:Node:SiteId"] = "TestSite",
|
||||
["ScadaBridge:Node:RemotingPort"] = "0",
|
||||
["ScadaBridge:Node:GrpcPort"] = "0",
|
||||
});
|
||||
|
||||
builder.Services.AddGrpc();
|
||||
builder.Services.AddSingleton<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer>();
|
||||
SiteServiceRegistration.Configure(builder.Services, builder.Configuration);
|
||||
|
||||
// Remove AkkaHostedService from running
|
||||
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(builder.Services);
|
||||
|
||||
var app = builder.Build();
|
||||
_disposables.Add(app);
|
||||
|
||||
// Build succeeds = DI container is valid and all services resolve
|
||||
Assert.NotNull(app);
|
||||
Assert.NotNull(app.Services);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteRole_ConfiguresKestrelForGrpc()
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.Configuration.Sources.Clear();
|
||||
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ScadaBridge:Node:Role"] = "Site",
|
||||
["ScadaBridge:Node:NodeHostname"] = "test-site",
|
||||
["ScadaBridge:Node:SiteId"] = "TestSite",
|
||||
["ScadaBridge:Node:RemotingPort"] = "0",
|
||||
["ScadaBridge:Node:GrpcPort"] = "0",
|
||||
});
|
||||
|
||||
builder.WebHost.ConfigureKestrel(options =>
|
||||
{
|
||||
options.ListenAnyIP(0, listenOptions =>
|
||||
{
|
||||
listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http2;
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddGrpc();
|
||||
builder.Services.AddSingleton<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer>();
|
||||
SiteServiceRegistration.Configure(builder.Services, builder.Configuration);
|
||||
|
||||
// Remove AkkaHostedService from running
|
||||
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(builder.Services);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Verify Kestrel IS configured (site now hosts gRPC via WebApplicationBuilder)
|
||||
var serverType = Type.GetType(
|
||||
"Microsoft.AspNetCore.Hosting.Server.IServer, Microsoft.AspNetCore.Hosting.Server.Abstractions");
|
||||
|
||||
if (serverType != null)
|
||||
{
|
||||
var server = app.Services.GetService(serverType);
|
||||
Assert.NotNull(server);
|
||||
}
|
||||
|
||||
(app as IDisposable)?.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostProject_DoesNotUseConditionalCompilation()
|
||||
{
|
||||
var hostProjectDir = FindHostProjectDirectory();
|
||||
Assert.NotNull(hostProjectDir);
|
||||
|
||||
var sourceFiles = Directory.GetFiles(hostProjectDir, "*.cs", SearchOption.TopDirectoryOnly);
|
||||
Assert.NotEmpty(sourceFiles);
|
||||
|
||||
foreach (var file in sourceFiles)
|
||||
{
|
||||
var content = File.ReadAllText(file);
|
||||
|
||||
Assert.DoesNotContain("#if", content);
|
||||
Assert.DoesNotContain("#ifdef", content);
|
||||
Assert.DoesNotContain("#ifndef", content);
|
||||
Assert.DoesNotContain("#elif", content);
|
||||
Assert.DoesNotContain("#else", content);
|
||||
Assert.DoesNotContain("#endif", content);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindHostProjectDirectory()
|
||||
{
|
||||
// Walk up from the test assembly location to find the src directory
|
||||
var assemblyDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;
|
||||
var dir = new DirectoryInfo(assemblyDir);
|
||||
|
||||
while (dir != null)
|
||||
{
|
||||
var hostPath = Path.Combine(dir.FullName, "src", "ZB.MOM.WW.ScadaBridge.Host");
|
||||
if (Directory.Exists(hostPath))
|
||||
return hostPath;
|
||||
dir = dir.Parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Serilog.Events;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Host-011: <c>ScadaBridge:Logging:MinimumLevel</c> must actually drive the Serilog
|
||||
/// minimum level. Previously the value was bound into <see cref="LoggingOptions"/>
|
||||
/// but never read, so editing it had no effect.
|
||||
/// </summary>
|
||||
public class LoggerConfigurationTests
|
||||
{
|
||||
private static IConfiguration BuildConfig(string? minimumLevel)
|
||||
{
|
||||
var values = new Dictionary<string, string?>();
|
||||
if (minimumLevel != null)
|
||||
values["ScadaBridge:Logging:MinimumLevel"] = minimumLevel;
|
||||
return new ConfigurationBuilder().AddInMemoryCollection(values).Build();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MinimumLevel_Warning_SuppressesInformationLogs()
|
||||
{
|
||||
var sink = new InMemorySink();
|
||||
var logger = LoggerConfigurationFactory
|
||||
.Build(BuildConfig("Warning"), "Central", "central", "node1")
|
||||
.WriteTo.Sink(sink)
|
||||
.CreateLogger();
|
||||
|
||||
logger.Information("info message");
|
||||
logger.Warning("warning message");
|
||||
|
||||
Assert.Single(sink.LogEvents);
|
||||
Assert.Equal(LogEventLevel.Warning, sink.LogEvents[0].Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MinimumLevel_Debug_AllowsDebugLogs()
|
||||
{
|
||||
var sink = new InMemorySink();
|
||||
var logger = LoggerConfigurationFactory
|
||||
.Build(BuildConfig("Debug"), "Site", "site-a", "node1")
|
||||
.WriteTo.Sink(sink)
|
||||
.CreateLogger();
|
||||
|
||||
logger.Debug("debug message");
|
||||
|
||||
Assert.Single(sink.LogEvents);
|
||||
Assert.Equal(LogEventLevel.Debug, sink.LogEvents[0].Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MinimumLevel_Absent_DefaultsToInformation()
|
||||
{
|
||||
var sink = new InMemorySink();
|
||||
var logger = LoggerConfigurationFactory
|
||||
.Build(BuildConfig(null), "Central", "central", "node1")
|
||||
.WriteTo.Sink(sink)
|
||||
.CreateLogger();
|
||||
|
||||
logger.Debug("debug message");
|
||||
logger.Information("info message");
|
||||
|
||||
Assert.Single(sink.LogEvents);
|
||||
Assert.Equal(LogEventLevel.Information, sink.LogEvents[0].Level);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Host-022: an unrecognised <c>ScadaBridge:Logging:MinimumLevel</c> (e.g. a typo
|
||||
/// like "Informaiton") must NOT abort startup but MUST emit a one-shot warning
|
||||
/// naming the offending value and the fallback so the silent coercion is
|
||||
/// visible. Null/blank is treated as "unset" and silently defaults.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ParseLevel_UnrecognisedValue_FallsBackAndWarns()
|
||||
{
|
||||
var writer = new StringWriter();
|
||||
|
||||
var result = LoggerConfigurationFactory.ParseLevel("Informaiton", writer);
|
||||
|
||||
Assert.Equal(LogEventLevel.Information, result);
|
||||
var warning = writer.ToString();
|
||||
Assert.Contains("warning", warning, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Informaiton", warning);
|
||||
Assert.Contains("Information", warning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLevel_NullOrBlank_FallsBackSilently()
|
||||
{
|
||||
var writer = new StringWriter();
|
||||
|
||||
var nullResult = LoggerConfigurationFactory.ParseLevel(null, writer);
|
||||
var blankResult = LoggerConfigurationFactory.ParseLevel(" ", writer);
|
||||
|
||||
Assert.Equal(LogEventLevel.Information, nullResult);
|
||||
Assert.Equal(LogEventLevel.Information, blankResult);
|
||||
Assert.Empty(writer.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLevel_RecognisedValue_NoWarning()
|
||||
{
|
||||
var writer = new StringWriter();
|
||||
|
||||
var result = LoggerConfigurationFactory.ParseLevel("Warning", writer);
|
||||
|
||||
Assert.Equal(LogEventLevel.Warning, result);
|
||||
Assert.Empty(writer.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Host-020: <c>ScadaBridge:Logging:MinimumLevel</c> is the documented source
|
||||
/// of truth for the Serilog floor, and the explicit <c>MinimumLevel.Is</c>
|
||||
/// call deliberately runs after <c>ReadFrom.Configuration(...)</c> so a
|
||||
/// <c>Serilog:MinimumLevel</c> entry is overridden. To make that precedence
|
||||
/// visible — instead of silently swallowed — <see cref="LoggerConfigurationFactory.Build(IConfiguration,string,string,string,TextWriter)"/>
|
||||
/// writes a one-shot warning when both keys are present. The warning must
|
||||
/// name both values and point the operator at the documented key. When the
|
||||
/// Serilog key is absent the warning is silent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Build_BothMinimumLevelKeysSet_WarnsAboutOverride()
|
||||
{
|
||||
var writer = new StringWriter();
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ScadaBridge:Logging:MinimumLevel"] = "Warning",
|
||||
["Serilog:MinimumLevel"] = "Debug",
|
||||
})
|
||||
.Build();
|
||||
|
||||
LoggerConfigurationFactory.Build(configuration, "Central", "central", "node1", writer);
|
||||
|
||||
var warning = writer.ToString();
|
||||
Assert.Contains("warning", warning, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Serilog:MinimumLevel", warning);
|
||||
Assert.Contains("ScadaBridge:Logging:MinimumLevel", warning);
|
||||
Assert.Contains("Debug", warning);
|
||||
Assert.Contains("Warning", warning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_OnlyScadaBridgeMinimumLevelSet_NoOverrideWarning()
|
||||
{
|
||||
var writer = new StringWriter();
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ScadaBridge:Logging:MinimumLevel"] = "Warning",
|
||||
})
|
||||
.Build();
|
||||
|
||||
LoggerConfigurationFactory.Build(configuration, "Central", "central", "node1", writer);
|
||||
|
||||
// No Serilog override -> no override-warning. (The ScadaBridge value is
|
||||
// a recognised level, so ParseLevel is silent too.)
|
||||
Assert.Empty(writer.ToString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for NodeIdentityProvider — surfaces the operator-configured semantic
|
||||
/// node name (e.g. node-a / node-b / central-a / central-b) used by downstream
|
||||
/// audit writers to stamp the SourceNode column.
|
||||
/// </summary>
|
||||
public class NodeIdentityProviderTests
|
||||
{
|
||||
private static INodeIdentityProvider BuildProvider(string nodeName)
|
||||
{
|
||||
var options = Options.Create(new NodeOptions { NodeName = nodeName });
|
||||
return new NodeIdentityProvider(options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NodeIdentityProvider_returns_configured_NodeName()
|
||||
{
|
||||
var provider = BuildProvider("central-a");
|
||||
|
||||
Assert.Equal("central-a", provider.NodeName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NodeIdentityProvider_returns_null_when_NodeName_unset()
|
||||
{
|
||||
var provider = BuildProvider(string.Empty);
|
||||
|
||||
Assert.Null(provider.NodeName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NodeIdentityProvider_returns_null_when_NodeName_whitespace()
|
||||
{
|
||||
var provider = BuildProvider(" ");
|
||||
|
||||
Assert.Null(provider.NodeName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NodeIdentityProvider_trims_whitespace()
|
||||
{
|
||||
var provider = BuildProvider(" node-a ");
|
||||
|
||||
Assert.Equal("node-a", provider.NodeName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
public class OptionsTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verify no component library (excluding Host) uses IConfiguration or accepts it
|
||||
/// in its AddXxx() extension method. Component libraries should only depend on
|
||||
/// DI abstractions and Options pattern, not on Configuration directly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ComponentLibraries_DoNotAcceptIConfigurationInAddMethods()
|
||||
{
|
||||
// All component assemblies (excluding Host itself and Commons)
|
||||
var componentAssemblies = new[]
|
||||
{
|
||||
typeof(ClusterInfrastructure.ServiceCollectionExtensions).Assembly,
|
||||
typeof(Communication.ServiceCollectionExtensions).Assembly,
|
||||
typeof(HealthMonitoring.ServiceCollectionExtensions).Assembly,
|
||||
typeof(ExternalSystemGateway.ServiceCollectionExtensions).Assembly,
|
||||
typeof(NotificationService.ServiceCollectionExtensions).Assembly,
|
||||
typeof(NotificationOutbox.ServiceCollectionExtensions).Assembly,
|
||||
typeof(TemplateEngine.ServiceCollectionExtensions).Assembly,
|
||||
typeof(DeploymentManager.ServiceCollectionExtensions).Assembly,
|
||||
typeof(Security.ServiceCollectionExtensions).Assembly,
|
||||
typeof(ConfigurationDatabase.ServiceCollectionExtensions).Assembly,
|
||||
typeof(SiteRuntime.ServiceCollectionExtensions).Assembly,
|
||||
typeof(DataConnectionLayer.ServiceCollectionExtensions).Assembly,
|
||||
typeof(StoreAndForward.ServiceCollectionExtensions).Assembly,
|
||||
typeof(SiteEventLogging.ServiceCollectionExtensions).Assembly,
|
||||
typeof(CentralUI.ServiceCollectionExtensions).Assembly,
|
||||
typeof(InboundAPI.ServiceCollectionExtensions).Assembly,
|
||||
};
|
||||
|
||||
foreach (var assembly in componentAssemblies)
|
||||
{
|
||||
// Check that the assembly does not reference Microsoft.Extensions.Configuration
|
||||
var configRef = assembly.GetReferencedAssemblies()
|
||||
.FirstOrDefault(a => a.Name == "Microsoft.Extensions.Configuration.Abstractions");
|
||||
|
||||
// Find all public static extension methods named Add*
|
||||
var extensionClasses = assembly.GetExportedTypes()
|
||||
.Where(t => t.IsClass && t.IsAbstract && t.IsSealed); // static classes
|
||||
|
||||
foreach (var cls in extensionClasses)
|
||||
{
|
||||
var addMethods = cls.GetMethods(BindingFlags.Public | BindingFlags.Static)
|
||||
.Where(m => m.Name.StartsWith("Add") || m.Name.StartsWith("Map"));
|
||||
|
||||
foreach (var method in addMethods)
|
||||
{
|
||||
var parameters = method.GetParameters();
|
||||
foreach (var param in parameters)
|
||||
{
|
||||
Assert.False(
|
||||
typeof(IConfiguration).IsAssignableFrom(param.ParameterType),
|
||||
$"{assembly.GetName().Name}: {cls.Name}.{method.Name} accepts IConfiguration parameter '{param.Name}'. " +
|
||||
"Component libraries should use the Options pattern, not IConfiguration.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Host-014 regression: REQ-HOST-8 requires the Host's Serilog sinks
|
||||
/// (console and file at minimum) to be <em>configuration-driven</em>. The sinks
|
||||
/// must be defined in a <c>Serilog</c> section in <c>appsettings.json</c> and
|
||||
/// applied via <c>ReadFrom.Configuration</c> — they must not be hard-coded in
|
||||
/// <c>Program.cs</c>, so an operator can change the file path, rolling interval or
|
||||
/// output template without recompiling.
|
||||
/// </summary>
|
||||
public class SerilogSinkConfigTests
|
||||
{
|
||||
private static string FindHostProjectDirectory()
|
||||
{
|
||||
var assemblyDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;
|
||||
var dir = new DirectoryInfo(assemblyDir);
|
||||
while (dir != null)
|
||||
{
|
||||
var hostPath = Path.Combine(dir.FullName, "src", "ZB.MOM.WW.ScadaBridge.Host");
|
||||
if (Directory.Exists(hostPath))
|
||||
return hostPath;
|
||||
dir = dir.Parent;
|
||||
}
|
||||
throw new DirectoryNotFoundException("Could not locate src/ZB.MOM.WW.ScadaBridge.Host");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShippedAppSettings_HasSerilogSection_WithConsoleAndFileSinks()
|
||||
{
|
||||
var path = Path.Combine(FindHostProjectDirectory(), "appsettings.json");
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
|
||||
Assert.True(
|
||||
doc.RootElement.TryGetProperty("Serilog", out var serilog),
|
||||
"appsettings.json must contain a `Serilog` section so ReadFrom.Configuration " +
|
||||
"drives the sinks (REQ-HOST-8 / Host-014).");
|
||||
|
||||
Assert.True(
|
||||
serilog.TryGetProperty("WriteTo", out var writeTo),
|
||||
"the `Serilog` section must contain a `WriteTo` array defining the sinks.");
|
||||
|
||||
var sinkNames = writeTo.EnumerateArray()
|
||||
.Select(e => e.TryGetProperty("Name", out var n) ? n.GetString() : null)
|
||||
.ToList();
|
||||
|
||||
Assert.Contains("Console", sinkNames);
|
||||
Assert.Contains("File", sinkNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoggerConfigurationFactory_AppliesConfiguredFileSink()
|
||||
{
|
||||
// A `Serilog` section in configuration must actually reach the built logger
|
||||
// via ReadFrom.Configuration — proving the sink set is configuration-driven.
|
||||
var logDir = Path.Combine(Path.GetTempPath(), "scadabridge-host014-" + Guid.NewGuid().ToString("N"));
|
||||
var logPath = Path.Combine(logDir, "test-.log");
|
||||
try
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Serilog:Using:0"] = "Serilog.Sinks.File",
|
||||
["Serilog:WriteTo:0:Name"] = "File",
|
||||
["Serilog:WriteTo:0:Args:path"] = logPath,
|
||||
["Serilog:WriteTo:0:Args:rollingInterval"] = "Day",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var logger = LoggerConfigurationFactory
|
||||
.Build(configuration, "Central", "central", "node1")
|
||||
.CreateLogger();
|
||||
|
||||
logger.Information("host-014 configured-sink probe");
|
||||
logger.Dispose();
|
||||
|
||||
Assert.True(Directory.Exists(logDir), "the configured file sink must have created its directory.");
|
||||
var written = Directory.GetFiles(logDir, "test-*.log");
|
||||
Assert.NotEmpty(written);
|
||||
Assert.Contains(
|
||||
"host-014 configured-sink probe",
|
||||
File.ReadAllText(written[0]));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(logDir))
|
||||
Directory.Delete(logDir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-14: Tests for Serilog structured logging with enriched properties.
|
||||
/// </summary>
|
||||
public class SerilogTests
|
||||
{
|
||||
[Fact]
|
||||
public void SerilogLogger_EnrichesWithNodeProperties()
|
||||
{
|
||||
var sink = new InMemorySink();
|
||||
|
||||
var logger = new LoggerConfiguration()
|
||||
.Enrich.WithProperty("SiteId", "TestSite")
|
||||
.Enrich.WithProperty("NodeHostname", "test-node1")
|
||||
.Enrich.WithProperty("NodeRole", "Site")
|
||||
.WriteTo.Sink(sink)
|
||||
.CreateLogger();
|
||||
|
||||
logger.Information("Test log message");
|
||||
|
||||
Assert.Single(sink.LogEvents);
|
||||
var logEvent = sink.LogEvents[0];
|
||||
|
||||
Assert.True(logEvent.Properties.ContainsKey("SiteId"));
|
||||
Assert.Equal("\"TestSite\"", logEvent.Properties["SiteId"].ToString());
|
||||
|
||||
Assert.True(logEvent.Properties.ContainsKey("NodeHostname"));
|
||||
Assert.Equal("\"test-node1\"", logEvent.Properties["NodeHostname"].ToString());
|
||||
|
||||
Assert.True(logEvent.Properties.ContainsKey("NodeRole"));
|
||||
Assert.Equal("\"Site\"", logEvent.Properties["NodeRole"].ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerilogLogger_CentralRole_EnrichesSiteIdAsCentral()
|
||||
{
|
||||
var sink = new InMemorySink();
|
||||
|
||||
var logger = new LoggerConfiguration()
|
||||
.Enrich.WithProperty("SiteId", "central")
|
||||
.Enrich.WithProperty("NodeHostname", "central-node1")
|
||||
.Enrich.WithProperty("NodeRole", "Central")
|
||||
.WriteTo.Sink(sink)
|
||||
.CreateLogger();
|
||||
|
||||
logger.Warning("Central warning");
|
||||
|
||||
Assert.Single(sink.LogEvents);
|
||||
var logEvent = sink.LogEvents[0];
|
||||
|
||||
Assert.Equal(LogEventLevel.Warning, logEvent.Level);
|
||||
Assert.Equal("\"central\"", logEvent.Properties["SiteId"].ToString());
|
||||
Assert.Equal("\"Central\"", logEvent.Properties["NodeRole"].ToString());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simple in-memory Serilog sink for testing.
|
||||
/// </summary>
|
||||
public class InMemorySink : Serilog.Core.ILogEventSink
|
||||
{
|
||||
public List<LogEvent> LogEvents { get; } = new();
|
||||
|
||||
public void Emit(LogEvent logEvent)
|
||||
{
|
||||
LogEvents.Add(logEvent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Host-010: startup preconditions (database migration) must tolerate a database
|
||||
/// that is briefly unavailable at boot — common when an app container and its DB
|
||||
/// container start together — via a bounded retry with backoff.
|
||||
/// </summary>
|
||||
public class StartupRetryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExecuteWithRetry_SucceedsFirstTry_RunsOnce()
|
||||
{
|
||||
var attempts = 0;
|
||||
await StartupRetry.ExecuteWithRetryAsync(
|
||||
"test-op",
|
||||
() => { attempts++; return Task.CompletedTask; },
|
||||
maxAttempts: 5,
|
||||
initialDelay: TimeSpan.FromMilliseconds(1),
|
||||
NullLogger.Instance);
|
||||
|
||||
Assert.Equal(1, attempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteWithRetry_TransientFailures_RetriesUntilSuccess()
|
||||
{
|
||||
var attempts = 0;
|
||||
await StartupRetry.ExecuteWithRetryAsync(
|
||||
"test-op",
|
||||
() =>
|
||||
{
|
||||
attempts++;
|
||||
if (attempts < 3)
|
||||
throw new InvalidOperationException("db not ready");
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
maxAttempts: 5,
|
||||
initialDelay: TimeSpan.FromMilliseconds(1),
|
||||
NullLogger.Instance);
|
||||
|
||||
Assert.Equal(3, attempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteWithRetry_ExhaustsAttempts_RethrowsLastException()
|
||||
{
|
||||
var attempts = 0;
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
StartupRetry.ExecuteWithRetryAsync(
|
||||
"test-op",
|
||||
() =>
|
||||
{
|
||||
attempts++;
|
||||
throw new InvalidOperationException($"failure {attempts}");
|
||||
},
|
||||
maxAttempts: 3,
|
||||
initialDelay: TimeSpan.FromMilliseconds(1),
|
||||
NullLogger.Instance,
|
||||
isTransient: _ => true));
|
||||
|
||||
Assert.Equal(3, attempts);
|
||||
Assert.Equal("failure 3", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteWithRetry_NonTransientFailure_RethrowsAfterSingleAttempt()
|
||||
{
|
||||
// Host-015: a permanent failure (e.g. a schema-version mismatch) must NOT be
|
||||
// retried — retrying it cannot succeed and only delays the fatal exit by
|
||||
// minutes. The isTransient predicate classifies it as non-retryable, so the
|
||||
// operation runs exactly once before the exception propagates.
|
||||
var attempts = 0;
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
StartupRetry.ExecuteWithRetryAsync(
|
||||
"test-op",
|
||||
() =>
|
||||
{
|
||||
attempts++;
|
||||
throw new InvalidOperationException("permanent schema mismatch");
|
||||
},
|
||||
maxAttempts: 8,
|
||||
initialDelay: TimeSpan.FromMilliseconds(1),
|
||||
NullLogger.Instance,
|
||||
isTransient: _ => false));
|
||||
|
||||
Assert.Equal(1, attempts);
|
||||
Assert.Equal("permanent schema mismatch", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteWithRetry_TransientThenPermanent_StopsAtPermanent()
|
||||
{
|
||||
// A transient fault is retried; a subsequent permanent fault is not.
|
||||
var attempts = 0;
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
StartupRetry.ExecuteWithRetryAsync(
|
||||
"test-op",
|
||||
() =>
|
||||
{
|
||||
attempts++;
|
||||
if (attempts == 1)
|
||||
throw new TimeoutException("transient");
|
||||
throw new InvalidOperationException("permanent");
|
||||
},
|
||||
maxAttempts: 8,
|
||||
initialDelay: TimeSpan.FromMilliseconds(1),
|
||||
NullLogger.Instance,
|
||||
isTransient: e => e is TimeoutException));
|
||||
|
||||
// 1 transient (retried) + 1 permanent (not retried) = 2.
|
||||
Assert.Equal(2, attempts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-11: Tests for StartupValidator configuration validation.
|
||||
/// </summary>
|
||||
public class StartupValidatorTests
|
||||
{
|
||||
private static IConfiguration BuildConfig(Dictionary<string, string?> values)
|
||||
{
|
||||
return new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(values)
|
||||
.Build();
|
||||
}
|
||||
|
||||
private static Dictionary<string, string?> ValidCentralConfig() => new()
|
||||
{
|
||||
["ScadaBridge:Node:Role"] = "Central",
|
||||
["ScadaBridge:Node:NodeHostname"] = "central-node1",
|
||||
["ScadaBridge:Node:RemotingPort"] = "8081",
|
||||
["ScadaBridge:Database:ConfigurationDb"] = "Server=localhost;Database=Config;",
|
||||
["ScadaBridge:Security:LdapServer"] = "ldap.example.com",
|
||||
["ScadaBridge:Security:JwtSigningKey"] = "test-signing-key-at-least-32-chars-long",
|
||||
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@central-node1:8081",
|
||||
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@central-node2:8081",
|
||||
};
|
||||
|
||||
private static Dictionary<string, string?> ValidSiteConfig() => new()
|
||||
{
|
||||
["ScadaBridge:Node:Role"] = "Site",
|
||||
["ScadaBridge:Node:NodeHostname"] = "site-a-node1",
|
||||
["ScadaBridge:Node:SiteId"] = "SiteA",
|
||||
["ScadaBridge:Node:RemotingPort"] = "8082",
|
||||
["ScadaBridge:Database:SiteDbPath"] = "./data/scadabridge.db",
|
||||
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@site-a-node1:8082",
|
||||
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@site-a-node2:8082",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ValidCentralConfig_PassesValidation()
|
||||
{
|
||||
var config = BuildConfig(ValidCentralConfig());
|
||||
var ex = Record.Exception(() => StartupValidator.Validate(config));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidSiteConfig_PassesValidation()
|
||||
{
|
||||
var config = BuildConfig(ValidSiteConfig());
|
||||
var ex = Record.Exception(() => StartupValidator.Validate(config));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingRole_FailsValidation()
|
||||
{
|
||||
var values = ValidCentralConfig();
|
||||
values.Remove("ScadaBridge:Node:Role");
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("Role must be 'Central' or 'Site'", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidRole_FailsValidation()
|
||||
{
|
||||
var values = ValidCentralConfig();
|
||||
values["ScadaBridge:Node:Role"] = "Unknown";
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("Role must be 'Central' or 'Site'", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyHostname_FailsValidation()
|
||||
{
|
||||
var values = ValidCentralConfig();
|
||||
values["ScadaBridge:Node:NodeHostname"] = "";
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("NodeHostname is required", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingHostname_FailsValidation()
|
||||
{
|
||||
var values = ValidCentralConfig();
|
||||
values.Remove("ScadaBridge:Node:NodeHostname");
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("NodeHostname is required", ex.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0")]
|
||||
[InlineData("-1")]
|
||||
[InlineData("65536")]
|
||||
[InlineData("abc")]
|
||||
[InlineData("")]
|
||||
public void InvalidPort_FailsValidation(string port)
|
||||
{
|
||||
var values = ValidCentralConfig();
|
||||
values["ScadaBridge:Node:RemotingPort"] = port;
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("RemotingPort must be 1-65535", ex.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1")]
|
||||
[InlineData("8081")]
|
||||
[InlineData("65535")]
|
||||
public void ValidPort_PassesValidation(string port)
|
||||
{
|
||||
var values = ValidCentralConfig();
|
||||
values["ScadaBridge:Node:RemotingPort"] = port;
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Record.Exception(() => StartupValidator.Validate(config));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Site_MissingSiteId_FailsValidation()
|
||||
{
|
||||
var values = ValidSiteConfig();
|
||||
values.Remove("ScadaBridge:Node:SiteId");
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("SiteId is required for Site nodes", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Central_MissingConfigurationDb_FailsValidation()
|
||||
{
|
||||
var values = ValidCentralConfig();
|
||||
values.Remove("ScadaBridge:Database:ConfigurationDb");
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("ConfigurationDb connection string required for Central", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Central_MissingMachineDataDb_PassesValidation()
|
||||
{
|
||||
// Host-008 regression: MachineDataDb is never consumed anywhere in the
|
||||
// system (only ConfigurationDb is wired into AddConfigurationDatabase).
|
||||
// It is no longer a required key, so its absence must not fail startup.
|
||||
var values = ValidCentralConfig();
|
||||
values.Remove("ScadaBridge:Database:MachineDataDb");
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Record.Exception(() => StartupValidator.Validate(config));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Central_MissingLdapServer_FailsValidation()
|
||||
{
|
||||
var values = ValidCentralConfig();
|
||||
values.Remove("ScadaBridge:Security:LdapServer");
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("LdapServer required for Central", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Central_MissingJwtSigningKey_FailsValidation()
|
||||
{
|
||||
var values = ValidCentralConfig();
|
||||
values.Remove("ScadaBridge:Security:JwtSigningKey");
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("JwtSigningKey required for Central", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Site_MissingSiteDbPath_FailsValidation()
|
||||
{
|
||||
var values = ValidSiteConfig();
|
||||
values.Remove("ScadaBridge:Database:SiteDbPath");
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("SiteDbPath required for Site nodes", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FewerThanTwoSeedNodes_FailsValidation()
|
||||
{
|
||||
var values = ValidCentralConfig();
|
||||
values.Remove("ScadaBridge:Cluster:SeedNodes:1");
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("SeedNodes must have at least 2 entries", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoSeedNodes_FailsValidation()
|
||||
{
|
||||
var values = ValidCentralConfig();
|
||||
values.Remove("ScadaBridge:Cluster:SeedNodes:0");
|
||||
values.Remove("ScadaBridge:Cluster:SeedNodes:1");
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("SeedNodes must have at least 2 entries", ex.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0")]
|
||||
[InlineData("-1")]
|
||||
[InlineData("65536")]
|
||||
[InlineData("abc")]
|
||||
public void Site_InvalidGrpcPort_FailsValidation(string grpcPort)
|
||||
{
|
||||
var values = ValidSiteConfig();
|
||||
values["ScadaBridge:Node:GrpcPort"] = grpcPort;
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("GrpcPort must be 1-65535", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Site_ValidGrpcPort_PassesValidation()
|
||||
{
|
||||
var values = ValidSiteConfig();
|
||||
values["ScadaBridge:Node:GrpcPort"] = "8083";
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Record.Exception(() => StartupValidator.Validate(config));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Central_InvalidGrpcPort_NotValidated()
|
||||
{
|
||||
var values = ValidCentralConfig();
|
||||
values["ScadaBridge:Node:GrpcPort"] = "0";
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Record.Exception(() => StartupValidator.Validate(config));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Site_SeedNodeOnGrpcPort_FailsValidation()
|
||||
{
|
||||
// Host-004 regression: a site seed node must reference an Akka remoting
|
||||
// endpoint, never the Kestrel HTTP/2 gRPC port. A seed node whose port
|
||||
// equals this node's GrpcPort would make a joining node attempt an
|
||||
// Akka.Remote TCP association against the gRPC listener and fail.
|
||||
var values = ValidSiteConfig();
|
||||
values["ScadaBridge:Node:GrpcPort"] = "8083";
|
||||
values["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@site-a-node1:8083";
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("must not target the gRPC port", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Site_SeedNodeOnDefaultGrpcPort_FailsValidation()
|
||||
{
|
||||
// GrpcPort is absent here, so the NodeOptions default of 8083 applies.
|
||||
// A seed node on 8083 must still be rejected.
|
||||
var values = ValidSiteConfig();
|
||||
values["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@site-a-node2:8083";
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("must not target the gRPC port", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Site_SeedNodesOnRemotingPort_PassesValidation()
|
||||
{
|
||||
// Two distinct site nodes, both seed entries on the remoting port (8082).
|
||||
var values = ValidSiteConfig();
|
||||
values["ScadaBridge:Node:GrpcPort"] = "8083";
|
||||
values["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@site-a-node1:8082";
|
||||
values["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@site-a-node2:8082";
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Record.Exception(() => StartupValidator.Validate(config));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Central_SeedNodeOnPort8083_PassesValidation()
|
||||
{
|
||||
// The gRPC-port rule applies to Site nodes only. A Central node has no
|
||||
// GrpcPort, so a seed node on 8083 must not be rejected.
|
||||
var values = ValidCentralConfig();
|
||||
values["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@central-node2:8083";
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Record.Exception(() => StartupValidator.Validate(config));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Site_GrpcPortEqualsRemotingPort_FailsValidation()
|
||||
{
|
||||
// Host-007 regression: REQ-HOST-4 requires GrpcPort to differ from
|
||||
// RemotingPort. Identical values cause Kestrel and Akka.Remote to
|
||||
// contend for the same port at runtime.
|
||||
var values = ValidSiteConfig();
|
||||
values["ScadaBridge:Node:RemotingPort"] = "8082";
|
||||
values["ScadaBridge:Node:GrpcPort"] = "8082";
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("GrpcPort must differ from RemotingPort", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Site_DefaultGrpcPortEqualsRemotingPort_FailsValidation()
|
||||
{
|
||||
// GrpcPort absent => NodeOptions default 8083. A site whose RemotingPort
|
||||
// is also 8083 must still be rejected.
|
||||
var values = ValidSiteConfig();
|
||||
values["ScadaBridge:Node:RemotingPort"] = "8083";
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("GrpcPort must differ from RemotingPort", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Site_GrpcPortDiffersFromRemotingPort_PassesValidation()
|
||||
{
|
||||
var values = ValidSiteConfig();
|
||||
values["ScadaBridge:Node:RemotingPort"] = "8082";
|
||||
values["ScadaBridge:Node:GrpcPort"] = "8083";
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Record.Exception(() => StartupValidator.Validate(config));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleErrors_AllReported()
|
||||
{
|
||||
var values = new Dictionary<string, string?>
|
||||
{
|
||||
// Role is missing, hostname is missing, port is missing
|
||||
};
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("Role must be 'Central' or 'Site'", ex.Message);
|
||||
Assert.Contains("NodeHostname is required", ex.Message);
|
||||
Assert.Contains("RemotingPort must be 1-65535", ex.Message);
|
||||
Assert.Contains("SeedNodes must have at least 2 entries", ex.Message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-17: Tests for Windows Service support.
|
||||
/// Verifies UseWindowsService() is called in Program.cs.
|
||||
/// </summary>
|
||||
public class WindowsServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProgramCs_CallsUseWindowsService()
|
||||
{
|
||||
var hostProjectDir = FindHostProjectDirectory();
|
||||
Assert.NotNull(hostProjectDir);
|
||||
|
||||
var programFile = Path.Combine(hostProjectDir, "Program.cs");
|
||||
Assert.True(File.Exists(programFile), "Program.cs not found");
|
||||
|
||||
var content = File.ReadAllText(programFile);
|
||||
|
||||
// Verify UseWindowsService() is called for both Central and Site paths
|
||||
var occurrences = content.Split("UseWindowsService()").Length - 1;
|
||||
Assert.True(occurrences >= 2,
|
||||
$"Expected UseWindowsService() to be called at least twice (Central and Site paths), found {occurrences} occurrence(s)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostProject_ReferencesWindowsServicesPackage()
|
||||
{
|
||||
var hostProjectDir = FindHostProjectDirectory();
|
||||
Assert.NotNull(hostProjectDir);
|
||||
|
||||
var csprojFile = Path.Combine(hostProjectDir, "ZB.MOM.WW.ScadaBridge.Host.csproj");
|
||||
Assert.True(File.Exists(csprojFile), "ZB.MOM.WW.ScadaBridge.Host.csproj not found");
|
||||
|
||||
var content = File.ReadAllText(csprojFile);
|
||||
Assert.Contains("Microsoft.Extensions.Hosting.WindowsServices", content);
|
||||
}
|
||||
|
||||
private static string? FindHostProjectDirectory()
|
||||
{
|
||||
var assemblyDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;
|
||||
var dir = new DirectoryInfo(assemblyDir);
|
||||
|
||||
while (dir != null)
|
||||
{
|
||||
var hostPath = Path.Combine(dir.FullName, "src", "ZB.MOM.WW.ScadaBridge.Host");
|
||||
if (Directory.Exists(hostPath))
|
||||
return hostPath;
|
||||
dir = dir.Parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Akka.TestKit.Xunit2" />
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="Serilog" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.Host/ZB.MOM.WW.ScadaBridge.Host.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user