test(integration): F21 — docker-compose + env-driven SQL/LDAP harness mode

Adds a real-infra mode for the integration test harness alongside the default
in-memory mode. Drops the previously-untested code paths (EF SqlServer
behaviors, real LDAP bind) under env-var control without breaking the
zero-infra default that CI runs.

- docker-compose.yml — minimal SQL 2022 (14331) + OpenLDAP (3894) stack
  (ports chosen to coexist with docker-dev/ on 14330/3893)
- HarnessMode record reads OTOPCUA_HARNESS_USE_SQL=1 / USE_LDAP=1 from env
- SQL mode: per-harness unique DB OtOpcUa_Harness_{guid}, EnsureCreated
  at startup, EnsureDeleted on dispose (best-effort)
- LDAP mode: drops StubLdapAuthService and configures real LdapAuthService
  against the compose'd OpenLDAP via Authentication:Ldap:* config keys
- Microsoft.EntityFrameworkCore.SqlServer added to the test project
- README documents both modes + the macOS no-Docker caveat

Default in-memory mode unchanged — all 9 existing tests still pass.
This commit is contained in:
Joseph Doherty
2026-05-26 07:25:16 -04:00
parent ba6e5dd7f9
commit b0a2bb037d
4 changed files with 214 additions and 47 deletions

View File

@@ -24,27 +24,36 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// Spins up two in-process <c>OtOpcUa.Host</c>-equivalent <see cref="WebApplication"/> instances
/// that share an in-memory <see cref="OtOpcUaConfigDbContext"/> and form a 2-member Akka cluster.
/// Both nodes carry the <c>admin</c> + <c>driver</c> roles, matching design §8's failover-test
/// 2-node profile.
/// that share an <see cref="OtOpcUaConfigDbContext"/> and form a 2-member Akka cluster. Both
/// nodes carry the <c>admin</c> + <c>driver</c> roles, matching design §8's failover-test 2-node
/// profile.
///
/// Why not <c>WebApplicationFactory&lt;Program&gt;</c>?
/// Program.cs reads <c>OTOPCUA_ROLES</c> 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 <see cref="WebApplicationBuilder"/> per node with
/// per-node config overrides. The production wiring is the same set of extensions
/// (<see cref="ServiceCollectionExtensions.AddOtOpcUaConfigDb"/>,
/// <see cref="AkkaCluster.ServiceCollectionExtensions.AddOtOpcUaCluster"/>,
/// <see cref="AddOtOpcUaAuth"/>, <see cref="AddOtOpcUaHealth"/>,
/// <see cref="WithOtOpcUaControlPlaneSingletons"/>,
/// <see cref="WithOtOpcUaRuntimeActors"/>).
/// <para>Default mode uses EF InMemoryDatabase + <see cref="StubLdapAuthService"/>. Optional
/// real-infra modes (env-var driven, see <see cref="HarnessMode.FromEnvironment"/>):</para>
/// <list type="bullet">
/// <item><c>OTOPCUA_HARNESS_USE_SQL=1</c> → swap the in-memory DB for SQL Server on
/// <c>localhost:14331</c> (see <c>docker-compose.yml</c>). Each harness gets a unique
/// database name (<c>OtOpcUa_Harness_{guid}</c>) created via <c>EnsureCreated</c>
/// and dropped via <c>EnsureDeleted</c> on dispose.</item>
/// <item><c>OTOPCUA_HARNESS_USE_LDAP=1</c> → drop the stub and point <c>LdapAuthService</c>
/// at OpenLDAP on <c>localhost:3894</c>.</item>
/// </list>
///
/// <para>Why not <c>WebApplicationFactory&lt;Program&gt;</c>? Program.cs reads <c>OTOPCUA_ROLES</c>
/// 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
/// <see cref="WebApplicationBuilder"/> per node with per-node config overrides.</para>
/// </summary>
public sealed class TwoNodeClusterHarness : IAsyncDisposable
{
public const string TestRoles = "admin,driver";
public string SharedDbName { get; } = $"two-node-cluster-{Guid.NewGuid():N}";
public HarnessMode Mode { get; } = HarnessMode.FromEnvironment();
private string? _sqlDbName;
private string? _sqlConnString;
public WebApplication NodeA { get; private set; } = null!;
public WebApplication NodeB { get; private set; } = null!;
@@ -65,20 +74,16 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
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);
if (harness.Mode.UseSqlServer)
{
harness._sqlDbName = $"OtOpcUa_Harness_{Guid.NewGuid():N}";
harness._sqlConnString = $"Server=localhost,14331;Database={harness._sqlDbName};User Id=sa;Password=OtOpcUa!Harness123;TrustServerCertificate=True;";
await EnsureSqlSchemaCreatedAsync(harness._sqlConnString);
}
harness.NodeB = await BuildNodeAsync(
host: LoopbackHost,
akkaPort: harness.NodeBAkkaPort,
seedHost: LoopbackHost,
seedAkkaPort: harness.NodeAAkkaPort,
dbName: harness.SharedDbName);
// Node A boots first as the seed.
harness.NodeA = await BuildNodeAsync(harness, NodeRole.Seed);
harness.NodeB = await BuildNodeAsync(harness, NodeRole.Joiner);
await WaitForClusterFormationAsync(
harness.NodeASystem,
@@ -102,17 +107,12 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
}
/// <summary>
/// Rebuilds node B on the same Akka port + same in-memory ConfigDb and waits for the cluster
/// Rebuilds node B on the same Akka port + same ConfigDb and waits for the cluster
/// to re-converge to 2 Up members. Use after <see cref="StopNodeBAsync"/> to test rejoin.
/// </summary>
public async Task RestartNodeBAsync(TimeSpan? formationTimeout = null)
{
NodeB = await BuildNodeAsync(
host: LoopbackHost,
akkaPort: NodeBAkkaPort,
seedHost: LoopbackHost,
seedAkkaPort: NodeAAkkaPort,
dbName: SharedDbName);
NodeB = await BuildNodeAsync(this, NodeRole.Joiner);
await WaitForClusterFormationAsync(
NodeASystem,
@@ -140,29 +140,56 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
$"Cluster did not converge to {expectedUpMembers} Up members within {timeout}. Actual={actual}");
}
private static async Task<WebApplication> BuildNodeAsync(
string host, int akkaPort, string seedHost, int seedAkkaPort, string dbName)
private enum NodeRole { Seed, Joiner }
private static async Task<WebApplication> BuildNodeAsync(TwoNodeClusterHarness harness, NodeRole role)
{
var akkaPort = role == NodeRole.Seed ? harness.NodeAAkkaPort : harness.NodeBAkkaPort;
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [] });
builder.WebHost.UseKestrel(o => o.Listen(System.Net.IPAddress.Parse(host), 0));
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
builder.WebHost.UseKestrel(o => o.Listen(System.Net.IPAddress.Parse(LoopbackHost), 0));
var configOverrides = new Dictionary<string, string?>
{
["ConnectionStrings:ConfigDb"] = "Server=test;Database=test;Trusted_Connection=True;TrustServerCertificate=True;",
["Cluster:Hostname"] = host,
["ConnectionStrings:ConfigDb"] = harness._sqlConnString
?? "Server=test;Database=test;Trusted_Connection=True;TrustServerCertificate=True;",
["Cluster:Hostname"] = LoopbackHost,
["Cluster:Port"] = akkaPort.ToString(),
["Cluster:PublicHostname"] = host,
["Cluster:SeedNodes:0"] = $"akka.tcp://otopcua@{seedHost}:{seedAkkaPort}",
["Cluster:PublicHostname"] = LoopbackHost,
["Cluster:SeedNodes:0"] = $"akka.tcp://otopcua@{LoopbackHost}:{harness.NodeAAkkaPort}",
["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",
});
};
if (harness.Mode.UseRealLdap)
{
configOverrides["Authentication:Ldap:Enabled"] = "true";
configOverrides["Authentication:Ldap:Server"] = "localhost";
configOverrides["Authentication:Ldap:Port"] = "3894";
configOverrides["Authentication:Ldap:UseTls"] = "false";
configOverrides["Authentication:Ldap:AllowInsecureLdap"] = "true";
configOverrides["Authentication:Ldap:SearchBase"] = "dc=lmxopcua,dc=local";
configOverrides["Authentication:Ldap:ServiceAccountDn"] = "cn=admin,dc=lmxopcua,dc=local";
configOverrides["Authentication:Ldap:ServiceAccountPassword"] = "ldapadmin";
}
builder.Configuration.AddInMemoryCollection(configOverrides);
// Provider swap: same DbContext type wired to either InMemory or SqlServer at startup.
if (harness.Mode.UseSqlServer)
{
builder.Services.AddDbContextFactory<OtOpcUaConfigDbContext>(opt => opt.UseSqlServer(harness._sqlConnString));
builder.Services.AddDbContext<OtOpcUaConfigDbContext>(opt => opt.UseSqlServer(harness._sqlConnString));
}
else
{
builder.Services.AddDbContextFactory<OtOpcUaConfigDbContext>(opt => opt.UseInMemoryDatabase(harness.SharedDbName));
builder.Services.AddDbContext<OtOpcUaConfigDbContext>(opt => opt.UseInMemoryDatabase(harness.SharedDbName));
}
// Replicate Program.cs role wiring with the harness-shared in-memory ConfigDb.
builder.Services.AddDbContextFactory<OtOpcUaConfigDbContext>(opt => opt.UseInMemoryDatabase(dbName));
builder.Services.AddDbContext<OtOpcUaConfigDbContext>(opt => opt.UseInMemoryDatabase(dbName));
builder.Services.AddOtOpcUaCluster(builder.Configuration);
builder.Services.AddAkka("otopcua", (ab, sp) =>
@@ -173,7 +200,8 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
});
builder.Services.AddOtOpcUaAuth(builder.Configuration);
builder.Services.AddSingleton<ILdapAuthService, StubLdapAuthService>();
if (!harness.Mode.UseRealLdap)
builder.Services.AddSingleton<ILdapAuthService, StubLdapAuthService>();
builder.Services.AddAdminUI();
builder.Services.AddSignalR();
builder.Services.AddOtOpcUaAdminClients();
@@ -190,6 +218,26 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
return app;
}
private static async Task EnsureSqlSchemaCreatedAsync(string connString)
{
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseSqlServer(connString)
.Options;
await using var db = new OtOpcUaConfigDbContext(opts);
// EnsureCreated bypasses migrations but builds the model in one shot — fine for tests.
// Production deployments use Migrate-To-V2.ps1 to apply EF migrations.
await db.Database.EnsureCreatedAsync();
}
private static async Task DropSqlDatabaseAsync(string connString)
{
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseSqlServer(connString)
.Options;
await using var db = new OtOpcUaConfigDbContext(opts);
await db.Database.EnsureDeletedAsync();
}
private static async Task WaitForClusterFormationAsync(ActorSystem a, ActorSystem b, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
@@ -221,6 +269,19 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
{
if (NodeB is not null) await NodeB.DisposeAsync();
if (NodeA is not null) await NodeA.DisposeAsync();
if (_sqlConnString is not null)
{
try { await DropSqlDatabaseAsync(_sqlConnString); }
catch { /* best-effort cleanup */ }
}
}
/// <summary>Captures the env-var driven harness mode at construction time.</summary>
public sealed record HarnessMode(bool UseSqlServer, bool UseRealLdap)
{
public static HarnessMode FromEnvironment() => new(
UseSqlServer: Environment.GetEnvironmentVariable("OTOPCUA_HARNESS_USE_SQL") == "1",
UseRealLdap: Environment.GetEnvironmentVariable("OTOPCUA_HARNESS_USE_LDAP") == "1");
}
private sealed class StubLdapAuthService : ILdapAuthService