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 { } /// /// Verifies that all expected Central-role actors are created at the correct paths /// when AkkaHostedService starts. /// [Collection("ActorSystem")] public class CentralActorPathTests : IAsyncLifetime { private WebApplicationFactory? _factory; private ActorSystem? _actorSystem; private string? _previousEnv; public Task InitializeAsync() { _previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central"); // Supply the pepper so the Central-role StartupValidator preflight (1fcc4f5) // passes before WebApplicationFactory gets a chance to overlay DI config. // The pre-host config builder includes AddEnvironmentVariables(), so this // env var is visible to StartupValidator.Validate() at Program.cs line 42. Environment.SetEnvironmentVariable("ScadaBridge__InboundApi__ApiKeyPepper", CentralDbTestEnvironment.TestPepper); _factory = new WebApplicationFactory() .WithWebHostBuilder(builder => { builder.ConfigureAppConfiguration((_, config) => { config.AddInMemoryCollection(new Dictionary { ["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!", // Task 1.4: LDAP settings nest under Security:Ldap (shared LdapOptions). // ServiceAccountDn is now required by the library's LdapOptionsValidator // (ValidateOnStart), so it must be present for the host to start. ["ScadaBridge:Security:Ldap:Server"] = "localhost", ["ScadaBridge:Security:Ldap:Port"] = "3893", ["ScadaBridge:Security:Ldap:Transport"] = "None", ["ScadaBridge:Security:Ldap:AllowInsecure"] = "true", ["ScadaBridge:Security:Ldap:SearchBase"] = "dc=zb,dc=local", ["ScadaBridge:Security:Ldap:ServiceAccountDn"] = "cn=admin,dc=zb,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) || 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(options => options.UseInMemoryDatabase($"ActorPathTests_{Guid.NewGuid()}")); }); }); // CreateClient triggers host startup including AkkaHostedService _ = _factory.CreateClient(); var akkaService = _factory.Services.GetRequiredService(); _actorSystem = akkaService.ActorSystem; return Task.CompletedTask; } public async Task DisposeAsync() { _factory?.Dispose(); Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", _previousEnv); Environment.SetEnvironmentVariable("ScadaBridge__InboundApi__ApiKeyPepper", null); 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( new Identify(path), TimeSpan.FromSeconds(5)); Assert.NotNull(identity.Subject); } } /// /// Verifies that all expected Site-role actors are created at the correct paths /// when AkkaHostedService starts. /// [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 { ["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(); _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( new Identify(path), TimeSpan.FromSeconds(5)); Assert.NotNull(identity.Subject); } }