diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/README.md b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/README.md new file mode 100644 index 0000000..b7450b6 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/README.md @@ -0,0 +1,48 @@ +# ZB.MOM.WW.OtOpcUa.Host.IntegrationTests + +Two-node Akka cluster integration tests on top of `TwoNodeClusterHarness`. + +## Default mode (no infra required) + +```bash +dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests +``` + +Uses `Microsoft.EntityFrameworkCore.InMemory` for `ConfigDb` and a stub `ILdapAuthService` that +accepts any username when the password is `valid-password`. Each harness instance creates a +unique in-memory database scoped to its lifetime. This is the mode CI runs by default. + +## Real-infra mode (SQL Server + OpenLDAP) + +When you need to exercise EF behaviors that diverge between providers (index uniqueness, +`RowVersion` concurrency, JSON columns, migration application) or a real LDAP bind, bring up +the bundled compose stack and set the env-var switches: + +```bash +docker compose -f tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/docker-compose.yml up -d + +export OTOPCUA_HARNESS_USE_SQL=1 +export OTOPCUA_HARNESS_USE_LDAP=1 +dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests + +docker compose -f tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/docker-compose.yml down -v +``` + +### SQL Server mode (`OTOPCUA_HARNESS_USE_SQL=1`) + +- Container: `mcr.microsoft.com/mssql/server:2022-latest` on `localhost:14331` +- Each `TwoNodeClusterHarness.StartAsync()` creates a unique database + `OtOpcUa_Harness_{guid}` via `Database.EnsureCreatedAsync()` and drops it on + `DisposeAsync()` (best-effort). +- Port `14331` chosen to avoid colliding with the `docker-dev/` fleet (which uses `14330`). + +### LDAP mode (`OTOPCUA_HARNESS_USE_LDAP=1`) + +- Container: `bitnami/openldap:2.6` on `localhost:3894` +- Users `alice` / `alice123` and `bob` / `bob123`, all under `ou=FleetAdmin`. +- Port `3894` chosen to avoid colliding with the `docker-dev/` fleet (which uses `3893`). + +## Local-dev caveat + +This dev VM (`DESKTOP-6JL3KKO`) does not run Docker locally. Real-infra mode runs on the +shared Linux Docker host (`10.100.0.35`) per `docs/v2/dev-environment.md`, or in CI on Linux. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs index ab7a8f6..6784c77 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs @@ -24,27 +24,36 @@ 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. +/// that share an 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 -/// (, -/// , -/// , , -/// , -/// ). +/// Default mode uses EF InMemoryDatabase + . Optional +/// real-infra modes (env-var driven, see ): +/// +/// OTOPCUA_HARNESS_USE_SQL=1 → swap the in-memory DB for SQL Server on +/// localhost:14331 (see docker-compose.yml). Each harness gets a unique +/// database name (OtOpcUa_Harness_{guid}) created via EnsureCreated +/// and dropped via EnsureDeleted on dispose. +/// OTOPCUA_HARNESS_USE_LDAP=1 → drop the stub and point LdapAuthService +/// at OpenLDAP on localhost:3894. +/// +/// +/// 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. /// 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 } /// - /// 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 to test rejoin. /// 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 BuildNodeAsync( - string host, int akkaPort, string seedHost, int seedAkkaPort, string dbName) + private enum NodeRole { Seed, Joiner } + + private static async Task 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 + builder.WebHost.UseKestrel(o => o.Listen(System.Net.IPAddress.Parse(LoopbackHost), 0)); + + var configOverrides = new Dictionary { - ["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(opt => opt.UseSqlServer(harness._sqlConnString)); + builder.Services.AddDbContext(opt => opt.UseSqlServer(harness._sqlConnString)); + } + else + { + builder.Services.AddDbContextFactory(opt => opt.UseInMemoryDatabase(harness.SharedDbName)); + builder.Services.AddDbContext(opt => opt.UseInMemoryDatabase(harness.SharedDbName)); + } - // 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) => @@ -173,7 +200,8 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable }); builder.Services.AddOtOpcUaAuth(builder.Configuration); - builder.Services.AddSingleton(); + if (!harness.Mode.UseRealLdap) + builder.Services.AddSingleton(); 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() + .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() + .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 */ } + } + } + + /// Captures the env-var driven harness mode at construction time. + 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 diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests.csproj b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests.csproj index 06bb70b..eff0f43 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests.csproj +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests.csproj @@ -13,6 +13,7 @@ + all diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/docker-compose.yml b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/docker-compose.yml new file mode 100644 index 0000000..9cc9335 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/docker-compose.yml @@ -0,0 +1,57 @@ +# Real-mode dependencies for ZB.MOM.WW.OtOpcUa.Host.IntegrationTests. +# +# The default harness (TwoNodeClusterHarness) uses EF InMemoryDatabase + StubLdapAuthService +# so the suite runs anywhere with zero infrastructure. This compose stack exists for two +# situations the in-memory mode can't cover: +# +# 1. EF behaviors that diverge between provider implementations — index uniqueness, +# RowVersion concurrency, JSON column round-trips, EF migration application. +# 2. Real LDAP binds against an OpenLDAP server with the dev users from +# C:\publish\glauth\auth.md. +# +# Activate by setting these env vars before running the suite: +# +# export OTOPCUA_HARNESS_USE_SQL=1 +# export OTOPCUA_HARNESS_USE_LDAP=1 +# docker compose -f tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/docker-compose.yml up -d +# dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "Category!=E2E" +# docker compose -f tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/docker-compose.yml down -v +# +# Ports differ from docker-dev/ on purpose so both stacks can run side-by-side: +# - SQL: 14331 (docker-dev uses 14330) +# - LDAP: 3894 (docker-dev uses 3893) +# +# DESKTOP-6JL3KKO note: Docker Desktop is not installed here. Run this stack on the shared +# Linux Docker host (10.100.0.35) per docs/v2/dev-environment.md, or in CI on Linux. + +name: otopcua-harness + +services: + + sql: + image: mcr.microsoft.com/mssql/server:2022-latest + environment: + ACCEPT_EULA: "Y" + SA_PASSWORD: "OtOpcUa!Harness123" + MSSQL_PID: Developer + ports: + - "14331:1433" + healthcheck: + test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'OtOpcUa!Harness123' -No -Q 'SELECT 1' || exit 1"] + interval: 10s + timeout: 5s + retries: 20 + + ldap: + # OpenLDAP image — same one docker-dev/ uses, just on a different port. Dev users + # alice/bob match the GLAuth fixtures so AuthEndpoints contract tests share creds. + image: bitnami/openldap:2.6 + environment: + LDAP_ROOT: "dc=lmxopcua,dc=local" + LDAP_ADMIN_USERNAME: "admin" + LDAP_ADMIN_PASSWORD: "ldapadmin" + LDAP_USERS: "alice,bob" + LDAP_PASSWORDS: "alice123,bob123" + LDAP_USER_DC: "ou=FleetAdmin" + ports: + - "3894:1389"