using System.Net.Sockets;
using Akka.Actor;
using Akka.Cluster;
using Akka.Hosting;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using ZB.MOM.WW.OtOpcUa.AdminUI;
using ZB.MOM.WW.OtOpcUa.AdminUI.Clients;
using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.ControlPlane;
using ZB.MOM.WW.OtOpcUa.Host.Health;
using ZB.MOM.WW.OtOpcUa.Runtime;
using ZB.MOM.WW.OtOpcUa.Security;
using ZB.MOM.WW.OtOpcUa.Security.Endpoints;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
///
/// Spins up two in-process OtOpcUa.Host-equivalent instances
/// that share an in-memory and form a 2-member Akka cluster.
/// Both nodes carry the admin + driver roles, matching design §8's failover-test
/// 2-node profile.
///
/// Why not WebApplicationFactory<Program>?
/// Program.cs reads OTOPCUA_ROLES from process env (shared across in-process WAF
/// instances) and writes both Serilog file sinks + Akka cluster TCP listener to the host
/// process — neither survives two parallel WAFs cleanly. This harness instead replays the
/// Program.cs DI graph from a clean per node with
/// per-node config overrides. The production wiring is the same set of extensions
/// (,
/// ,
/// , ,
/// ,
/// ).
///
public sealed class TwoNodeClusterHarness : IAsyncDisposable
{
public const string TestRoles = "admin,driver";
public string SharedDbName { get; } = $"two-node-cluster-{Guid.NewGuid():N}";
public WebApplication NodeA { get; private set; } = null!;
public WebApplication NodeB { get; private set; } = null!;
public int NodeAAkkaPort { get; private set; }
public int NodeBAkkaPort { get; private set; }
// Both nodes bind to 127.0.0.1 — ClusterRoleInfo + ConfigPublishCoordinator encode
// host:port into NodeId so the cluster membership stays distinct on different ports.
public const string LoopbackHost = "127.0.0.1";
public ActorSystem NodeASystem => NodeA.Services.GetRequiredService();
public ActorSystem NodeBSystem => NodeB.Services.GetRequiredService();
/// Boots both nodes and waits up to for cluster convergence.
public static async Task StartAsync(TimeSpan? formationTimeout = null)
{
var harness = new TwoNodeClusterHarness();
harness.NodeAAkkaPort = AllocateFreePort();
harness.NodeBAkkaPort = AllocateFreePort();
// Node A boots first as the seed.
harness.NodeA = await BuildNodeAsync(
host: LoopbackHost,
akkaPort: harness.NodeAAkkaPort,
seedHost: LoopbackHost,
seedAkkaPort: harness.NodeAAkkaPort,
dbName: harness.SharedDbName);
harness.NodeB = await BuildNodeAsync(
host: LoopbackHost,
akkaPort: harness.NodeBAkkaPort,
seedHost: LoopbackHost,
seedAkkaPort: harness.NodeAAkkaPort,
dbName: harness.SharedDbName);
await WaitForClusterFormationAsync(
harness.NodeASystem,
harness.NodeBSystem,
formationTimeout ?? TimeSpan.FromSeconds(20));
return harness;
}
private static async Task BuildNodeAsync(
string host, int akkaPort, string seedHost, int seedAkkaPort, string dbName)
{
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [] });
builder.WebHost.UseKestrel(o => o.Listen(System.Net.IPAddress.Parse(host), 0));
builder.Configuration.AddInMemoryCollection(new Dictionary
{
["ConnectionStrings:ConfigDb"] = "Server=test;Database=test;Trusted_Connection=True;TrustServerCertificate=True;",
["Cluster:Hostname"] = host,
["Cluster:Port"] = akkaPort.ToString(),
["Cluster:PublicHostname"] = host,
["Cluster:SeedNodes:0"] = $"akka.tcp://otopcua@{seedHost}:{seedAkkaPort}",
["Cluster:Roles:0"] = "admin",
["Cluster:Roles:1"] = "driver",
["Security:Jwt:SigningKey"] = "two-node-harness-test-signing-key-with-enough-bytes-for-hs256",
["Security:Jwt:Issuer"] = "otopcua-test",
["Security:Jwt:Audience"] = "otopcua-test",
});
// Replicate Program.cs role wiring with the harness-shared in-memory ConfigDb.
builder.Services.AddDbContextFactory(opt => opt.UseInMemoryDatabase(dbName));
builder.Services.AddDbContext(opt => opt.UseInMemoryDatabase(dbName));
builder.Services.AddOtOpcUaCluster(builder.Configuration);
builder.Services.AddAkka("otopcua", (ab, sp) =>
{
ab.WithOtOpcUaClusterBootstrap(sp);
ab.WithOtOpcUaControlPlaneSingletons();
ab.WithOtOpcUaRuntimeActors();
});
builder.Services.AddOtOpcUaAuth(builder.Configuration);
builder.Services.AddSingleton();
builder.Services.AddAdminUI();
builder.Services.AddSignalR();
builder.Services.AddOtOpcUaAdminClients();
builder.Services.AddOtOpcUaHealth();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapOtOpcUaAuth();
app.MapOtOpcUaHubs();
app.MapOtOpcUaHealth();
await app.StartAsync();
return app;
}
private static async Task WaitForClusterFormationAsync(ActorSystem a, ActorSystem b, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
var aMembers = Akka.Cluster.Cluster.Get(a).State.Members
.Where(m => m.Status == MemberStatus.Up).ToArray();
var bMembers = Akka.Cluster.Cluster.Get(b).State.Members
.Where(m => m.Status == MemberStatus.Up).ToArray();
if (aMembers.Length >= 2 && bMembers.Length >= 2) return;
await Task.Delay(200);
}
throw new TimeoutException(
$"Cluster did not form within {timeout}. " +
$"A up={Akka.Cluster.Cluster.Get(a).State.Members.Count(m => m.Status == MemberStatus.Up)}, " +
$"B up={Akka.Cluster.Cluster.Get(b).State.Members.Count(m => m.Status == MemberStatus.Up)}");
}
private static int AllocateFreePort()
{
using var listener = new TcpListener(System.Net.IPAddress.Parse(LoopbackHost), 0);
listener.Start();
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
public async ValueTask DisposeAsync()
{
if (NodeB is not null) await NodeB.DisposeAsync();
if (NodeA is not null) await NodeA.DisposeAsync();
}
private sealed class StubLdapAuthService : ILdapAuthService
{
public Task AuthenticateAsync(string username, string password, CancellationToken ct = default)
=> Task.FromResult(new LdapAuthResult(
Success: password == "valid-password",
DisplayName: username,
Username: username,
Groups: ["FleetAdmin"],
Roles: ["FleetAdmin"],
Error: null));
}
}