From 899dec6b6fa9bd9c00d21f2037716bf6c20d4140 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 18 Mar 2026 02:41:18 -0400 Subject: [PATCH] feat: wire ExternalSystem, Database, and Notify APIs into script runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IServiceProvider now flows through the actor chain (DeploymentManagerActor → InstanceActor → ScriptActor → ScriptExecutionActor) so scripts can resolve IExternalSystemClient, IDatabaseGateway, and INotificationDeliveryService from DI. ScriptGlobals exposes ExternalSystem, Database, Notify, and Scripts as top-level properties so scripts can use them without the Instance. prefix. --- .../Actors/AkkaHostedService.cs | 3 +- src/ScadaLink.Host/Program.cs | 47 +- src/ScadaLink.Host/SiteServiceRegistration.cs | 63 +++ .../Actors/DeploymentManagerActor.cs | 8 +- .../Actors/InstanceActor.cs | 8 +- .../Actors/ScriptActor.cs | 8 +- .../Actors/ScriptExecutionActor.cs | 31 +- .../Scripts/ScriptCompilationService.cs | 24 + tests/ScadaLink.Host.Tests/ActorPathTests.cs | 197 ++++++++ .../CompositionRootTests.cs | 429 ++++++++++++++++++ .../ScadaLink.Host.Tests/HostStartupTests.cs | 48 +- .../ScadaLink.Host.Tests.csproj | 1 + 12 files changed, 767 insertions(+), 100 deletions(-) create mode 100644 src/ScadaLink.Host/SiteServiceRegistration.cs create mode 100644 tests/ScadaLink.Host.Tests/ActorPathTests.cs create mode 100644 tests/ScadaLink.Host.Tests/CompositionRootTests.cs diff --git a/src/ScadaLink.Host/Actors/AkkaHostedService.cs b/src/ScadaLink.Host/Actors/AkkaHostedService.cs index e53aa86..4d2acaa 100644 --- a/src/ScadaLink.Host/Actors/AkkaHostedService.cs +++ b/src/ScadaLink.Host/Actors/AkkaHostedService.cs @@ -241,7 +241,8 @@ akka {{ siteRuntimeOptionsValue, dmLogger, dclManager, - siteHealthCollector)), + siteHealthCollector, + _serviceProvider)), terminationMessage: PoisonPill.Instance, settings: ClusterSingletonManagerSettings.Create(_actorSystem!) .WithRole(siteRole) diff --git a/src/ScadaLink.Host/Program.cs b/src/ScadaLink.Host/Program.cs index c8a6aa3..98ca6ec 100644 --- a/src/ScadaLink.Host/Program.cs +++ b/src/ScadaLink.Host/Program.cs @@ -4,7 +4,6 @@ using ScadaLink.CentralUI; using ScadaLink.ClusterInfrastructure; using ScadaLink.Communication; using ScadaLink.ConfigurationDatabase; -using ScadaLink.DataConnectionLayer; using ScadaLink.DeploymentManager; using ScadaLink.ExternalSystemGateway; using ScadaLink.HealthMonitoring; @@ -15,9 +14,6 @@ using ScadaLink.InboundAPI; using ScadaLink.ManagementService; using ScadaLink.NotificationService; using ScadaLink.Security; -using ScadaLink.SiteEventLogging; -using ScadaLink.SiteRuntime; -using ScadaLink.StoreAndForward; using ScadaLink.TemplateEngine; using Serilog; @@ -98,7 +94,7 @@ try builder.Services.AddHostedService(sp => sp.GetRequiredService()); // Options binding - BindSharedOptions(builder.Services, builder.Configuration); + SiteServiceRegistration.BindSharedOptions(builder.Services, builder.Configuration); builder.Services.Configure(builder.Configuration.GetSection("ScadaLink:Security")); builder.Services.Configure(builder.Configuration.GetSection("ScadaLink:InboundApi")); @@ -148,35 +144,7 @@ try builder.ConfigureServices((context, services) => { - // Shared components - services.AddClusterInfrastructure(); - services.AddCommunication(); - services.AddSiteHealthMonitoring(); - services.AddExternalSystemGateway(); - services.AddNotificationService(); - - // Health report transport: sends SiteHealthReport to SiteCommunicationActor via Akka - services.AddSingleton(); - services.AddSingleton(); - - // Site-only components — AddSiteRuntime registers SiteStorageService with SQLite path - // and site-local repository implementations (IExternalSystemRepository, INotificationRepository) - var siteDbPath = context.Configuration["ScadaLink:Database:SiteDbPath"] ?? "site.db"; - services.AddSiteRuntime($"Data Source={siteDbPath}"); - services.AddDataConnectionLayer(); - services.AddStoreAndForward(); - services.AddSiteEventLogging(); - - // WP-13: Akka.NET bootstrap via hosted service - services.AddSingleton(); - services.AddHostedService(sp => sp.GetRequiredService()); - - // Options binding - BindSharedOptions(services, context.Configuration); - services.Configure(context.Configuration.GetSection("ScadaLink:SiteRuntime")); - services.Configure(context.Configuration.GetSection("ScadaLink:DataConnection")); - services.Configure(context.Configuration.GetSection("ScadaLink:StoreAndForward")); - services.Configure(context.Configuration.GetSection("ScadaLink:SiteEventLog")); + SiteServiceRegistration.Configure(services, context.Configuration); }); var host = builder.Build(); @@ -197,17 +165,6 @@ finally await Log.CloseAndFlushAsync(); } -static void BindSharedOptions(IServiceCollection services, IConfiguration config) -{ - services.Configure(config.GetSection("ScadaLink:Node")); - services.Configure(config.GetSection("ScadaLink:Cluster")); - services.Configure(config.GetSection("ScadaLink:Database")); - services.Configure(config.GetSection("ScadaLink:Communication")); - services.Configure(config.GetSection("ScadaLink:HealthMonitoring")); - services.Configure(config.GetSection("ScadaLink:Notification")); - services.Configure(config.GetSection("ScadaLink:Logging")); -} - /// /// Exposes the auto-generated Program class for test infrastructure (e.g. WebApplicationFactory). /// diff --git a/src/ScadaLink.Host/SiteServiceRegistration.cs b/src/ScadaLink.Host/SiteServiceRegistration.cs new file mode 100644 index 0000000..9031ae9 --- /dev/null +++ b/src/ScadaLink.Host/SiteServiceRegistration.cs @@ -0,0 +1,63 @@ +using ScadaLink.ClusterInfrastructure; +using ScadaLink.Communication; +using ScadaLink.DataConnectionLayer; +using ScadaLink.ExternalSystemGateway; +using ScadaLink.HealthMonitoring; +using ScadaLink.Host.Actors; +using ScadaLink.NotificationService; +using ScadaLink.SiteEventLogging; +using ScadaLink.SiteRuntime; +using ScadaLink.StoreAndForward; + +namespace ScadaLink.Host; + +/// +/// Extracted site-role DI registrations so both Program.cs and tests +/// use the same composition root. +/// +public static class SiteServiceRegistration +{ + public static void Configure(IServiceCollection services, IConfiguration config) + { + // Shared components + services.AddClusterInfrastructure(); + services.AddCommunication(); + services.AddSiteHealthMonitoring(); + services.AddExternalSystemGateway(); + services.AddNotificationService(); + + // Health report transport: sends SiteHealthReport to SiteCommunicationActor via Akka + services.AddSingleton(); + services.AddSingleton(); + + // Site-only components — AddSiteRuntime registers SiteStorageService with SQLite path + // and site-local repository implementations (IExternalSystemRepository, INotificationRepository) + var siteDbPath = config["ScadaLink:Database:SiteDbPath"] ?? "site.db"; + services.AddSiteRuntime($"Data Source={siteDbPath}"); + services.AddDataConnectionLayer(); + services.AddStoreAndForward(); + services.AddSiteEventLogging(); + + // WP-13: Akka.NET bootstrap via hosted service + services.AddSingleton(); + services.AddHostedService(sp => sp.GetRequiredService()); + + // Options binding + BindSharedOptions(services, config); + services.Configure(config.GetSection("ScadaLink:SiteRuntime")); + services.Configure(config.GetSection("ScadaLink:DataConnection")); + services.Configure(config.GetSection("ScadaLink:StoreAndForward")); + services.Configure(config.GetSection("ScadaLink:SiteEventLog")); + } + + public static void BindSharedOptions(IServiceCollection services, IConfiguration config) + { + services.Configure(config.GetSection("ScadaLink:Node")); + services.Configure(config.GetSection("ScadaLink:Cluster")); + services.Configure(config.GetSection("ScadaLink:Database")); + services.Configure(config.GetSection("ScadaLink:Communication")); + services.Configure(config.GetSection("ScadaLink:HealthMonitoring")); + services.Configure(config.GetSection("ScadaLink:Notification")); + services.Configure(config.GetSection("ScadaLink:Logging")); + } +} diff --git a/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs b/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs index f25be81..df5dec8 100644 --- a/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs +++ b/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs @@ -32,6 +32,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers private readonly ILogger _logger; private readonly IActorRef? _dclManager; private readonly ISiteHealthCollector? _healthCollector; + private readonly IServiceProvider? _serviceProvider; private readonly Dictionary _instanceActors = new(); private int _totalDeployedCount; @@ -45,7 +46,8 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers SiteRuntimeOptions options, ILogger logger, IActorRef? dclManager = null, - ISiteHealthCollector? healthCollector = null) + ISiteHealthCollector? healthCollector = null, + IServiceProvider? serviceProvider = null) { _storage = storage; _compilationService = compilationService; @@ -54,6 +56,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers _options = options; _dclManager = dclManager; _healthCollector = healthCollector; + _serviceProvider = serviceProvider; _logger = logger; // Lifecycle commands @@ -561,7 +564,8 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers _options, loggerFactory.CreateLogger(), _dclManager, - _healthCollector)); + _healthCollector, + _serviceProvider)); var actorRef = Context.ActorOf(props, instanceName); _instanceActors[instanceName] = actorRef; diff --git a/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs b/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs index 742b346..fb2e7a4 100644 --- a/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs +++ b/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs @@ -39,6 +39,7 @@ public class InstanceActor : ReceiveActor private readonly SiteRuntimeOptions _options; private readonly ILogger _logger; private readonly ISiteHealthCollector? _healthCollector; + private readonly IServiceProvider? _serviceProvider; private readonly Dictionary _attributes = new(); private readonly Dictionary _attributeQualities = new(); private readonly Dictionary _alarmStates = new(); @@ -64,7 +65,8 @@ public class InstanceActor : ReceiveActor SiteRuntimeOptions options, ILogger logger, IActorRef? dclManager = null, - ISiteHealthCollector? healthCollector = null) + ISiteHealthCollector? healthCollector = null, + IServiceProvider? serviceProvider = null) { _instanceUniqueName = instanceUniqueName; _storage = storage; @@ -75,6 +77,7 @@ public class InstanceActor : ReceiveActor _logger = logger; _dclManager = dclManager; _healthCollector = healthCollector; + _serviceProvider = serviceProvider; // Deserialize the flattened configuration _configuration = JsonSerializer.Deserialize(configJson); @@ -479,7 +482,8 @@ public class InstanceActor : ReceiveActor _sharedScriptLibrary, _options, _logger, - _healthCollector)); + _healthCollector, + _serviceProvider)); var actorRef = Context.ActorOf(props, $"script-{script.CanonicalName}"); _scriptActors[script.CanonicalName] = actorRef; diff --git a/src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs b/src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs index 71b5e92..ef0a872 100644 --- a/src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs +++ b/src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs @@ -31,6 +31,7 @@ public class ScriptActor : ReceiveActor, IWithTimers private readonly SiteRuntimeOptions _options; private readonly ILogger _logger; private readonly ISiteHealthCollector? _healthCollector; + private readonly IServiceProvider? _serviceProvider; private Script? _compiledScript; private ScriptTriggerConfig? _triggerConfig; @@ -49,7 +50,8 @@ public class ScriptActor : ReceiveActor, IWithTimers SharedScriptLibrary sharedScriptLibrary, SiteRuntimeOptions options, ILogger logger, - ISiteHealthCollector? healthCollector = null) + ISiteHealthCollector? healthCollector = null, + IServiceProvider? serviceProvider = null) { _scriptName = scriptName; _instanceName = instanceName; @@ -59,6 +61,7 @@ public class ScriptActor : ReceiveActor, IWithTimers _options = options; _logger = logger; _healthCollector = healthCollector; + _serviceProvider = serviceProvider; _minTimeBetweenRuns = scriptConfig.MinTimeBetweenRuns; // Parse trigger configuration @@ -212,7 +215,8 @@ public class ScriptActor : ReceiveActor, IWithTimers replyTo, correlationId, _logger, - _healthCollector)); + _healthCollector, + _serviceProvider)); Context.ActorOf(props, executionId); } diff --git a/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs b/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs index fe02910..8f1787f 100644 --- a/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs +++ b/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs @@ -1,6 +1,8 @@ using Akka.Actor; using Microsoft.CodeAnalysis.Scripting; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Messages.ScriptExecution; using ScadaLink.HealthMonitoring; using ScadaLink.SiteRuntime.Scripts; @@ -30,7 +32,8 @@ public class ScriptExecutionActor : ReceiveActor IActorRef replyTo, string correlationId, ILogger logger, - ISiteHealthCollector? healthCollector = null) + ISiteHealthCollector? healthCollector = null, + IServiceProvider? serviceProvider = null) { // Immediately begin execution var self = Self; @@ -39,7 +42,7 @@ public class ScriptExecutionActor : ReceiveActor ExecuteScript( scriptName, instanceName, compiledScript, parameters, callDepth, instanceActor, sharedScriptLibrary, options, replyTo, correlationId, - self, parent, logger, healthCollector); + self, parent, logger, healthCollector, serviceProvider); } private static void ExecuteScript( @@ -56,16 +59,31 @@ public class ScriptExecutionActor : ReceiveActor IActorRef self, IActorRef parent, ILogger logger, - ISiteHealthCollector? healthCollector) + ISiteHealthCollector? healthCollector, + IServiceProvider? serviceProvider) { var timeout = TimeSpan.FromSeconds(options.ScriptExecutionTimeoutSeconds); // CTS must be created inside the async lambda so it outlives this method _ = Task.Run(async () => { + IServiceScope? serviceScope = null; using var cts = new CancellationTokenSource(timeout); try { + // Resolve integration services from DI (scoped lifetime) + IExternalSystemClient? externalSystemClient = null; + IDatabaseGateway? databaseGateway = null; + INotificationDeliveryService? notificationService = null; + + if (serviceProvider != null) + { + serviceScope = serviceProvider.CreateScope(); + externalSystemClient = serviceScope.ServiceProvider.GetService(); + databaseGateway = serviceScope.ServiceProvider.GetService(); + notificationService = serviceScope.ServiceProvider.GetService(); + } + var context = new ScriptRuntimeContext( instanceActor, self, @@ -74,7 +92,10 @@ public class ScriptExecutionActor : ReceiveActor options.MaxScriptCallDepth, timeout, instanceName, - logger); + logger, + externalSystemClient, + databaseGateway, + notificationService); var globals = new ScriptGlobals { @@ -123,6 +144,8 @@ public class ScriptExecutionActor : ReceiveActor } finally { + // Dispose the DI scope (and scoped services) after script execution completes + serviceScope?.Dispose(); // Stop self after execution completes self.Tell(PoisonPill.Instance); } diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScriptCompilationService.cs b/src/ScadaLink.SiteRuntime/Scripts/ScriptCompilationService.cs index da70924..c49b79e 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/ScriptCompilationService.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/ScriptCompilationService.cs @@ -178,4 +178,28 @@ public class ScriptGlobals public IReadOnlyDictionary Parameters { get; set; } = new Dictionary(); public CancellationToken CancellationToken { get; set; } + + /// + /// Top-level ExternalSystem access for scripts (delegates to Instance.ExternalSystem). + /// Usage: ExternalSystem.Call("systemName", "methodName", params) + /// + public ScriptRuntimeContext.ExternalSystemHelper ExternalSystem => Instance.ExternalSystem; + + /// + /// Top-level Database access for scripts (delegates to Instance.Database). + /// Usage: Database.Connection("name") or Database.CachedWrite("name", "sql", params) + /// + public ScriptRuntimeContext.DatabaseHelper Database => Instance.Database; + + /// + /// Top-level Notify access for scripts (delegates to Instance.Notify). + /// Usage: Notify.To("listName").Send("subject", "message") + /// + public ScriptRuntimeContext.NotifyHelper Notify => Instance.Notify; + + /// + /// Top-level Scripts access for shared script calls (delegates to Instance.Scripts). + /// Usage: Scripts.CallShared("scriptName", params) + /// + public ScriptRuntimeContext.ScriptCallHelper Scripts => Instance.Scripts; } diff --git a/tests/ScadaLink.Host.Tests/ActorPathTests.cs b/tests/ScadaLink.Host.Tests/ActorPathTests.cs new file mode 100644 index 0000000..3dd5c25 --- /dev/null +++ b/tests/ScadaLink.Host.Tests/ActorPathTests.cs @@ -0,0 +1,197 @@ +using Akka.Actor; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using ScadaLink.ConfigurationDatabase; +using ScadaLink.Host; +using ScadaLink.Host.Actors; + +namespace ScadaLink.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"); + + _factory = new WebApplicationFactory() + .WithWebHostBuilder(builder => + { + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["ScadaLink:Node:NodeHostname"] = "localhost", + ["ScadaLink:Node:RemotingPort"] = "0", + ["ScadaLink:Cluster:SeedNodes:0"] = "akka.tcp://scadalink@localhost:25510", + ["ScadaLink:Cluster:SeedNodes:1"] = "akka.tcp://scadalink@localhost:25520", + ["ScadaLink:Cluster:MinNrOfMembers"] = "1", + ["ScadaLink:Database:SkipMigrations"] = "true", + ["ScadaLink:Security:JwtSigningKey"] = "test-signing-key-must-be-at-least-32-chars-long!", + ["ScadaLink:Security:LdapServer"] = "localhost", + ["ScadaLink:Security:LdapPort"] = "3893", + ["ScadaLink:Security:LdapUseTls"] = "false", + ["ScadaLink:Security:AllowInsecureLdap"] = "true", + ["ScadaLink:Security:LdapSearchBase"] = "dc=scadalink,dc=local", + }); + }); + builder.UseSetting("ScadaLink:Node:Role", "Central"); + builder.UseSetting("ScadaLink: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(ScadaLinkDbContext) || + 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); + 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"); + + 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(), $"scadalink_actor_test_{Guid.NewGuid()}.db"); + + var builder = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder(); + builder.ConfigureAppConfiguration(config => + { + config.Sources.Clear(); + config.AddInMemoryCollection(new Dictionary + { + ["ScadaLink:Node:Role"] = "Site", + ["ScadaLink:Node:NodeHostname"] = "localhost", + ["ScadaLink:Node:SiteId"] = "TestSite", + ["ScadaLink:Node:RemotingPort"] = "0", + ["ScadaLink:Cluster:SeedNodes:0"] = "akka.tcp://scadalink@localhost:25510", + ["ScadaLink:Cluster:SeedNodes:1"] = "akka.tcp://scadalink@localhost:25520", + ["ScadaLink:Cluster:MinNrOfMembers"] = "1", + ["ScadaLink:Database:SiteDbPath"] = _tempDbPath, + // Configure a dummy central contact point to trigger ClusterClient creation + ["ScadaLink:Communication:CentralContactPoints:0"] = "akka.tcp://scadalink@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); + } +} diff --git a/tests/ScadaLink.Host.Tests/CompositionRootTests.cs b/tests/ScadaLink.Host.Tests/CompositionRootTests.cs new file mode 100644 index 0000000..1c41dba --- /dev/null +++ b/tests/ScadaLink.Host.Tests/CompositionRootTests.cs @@ -0,0 +1,429 @@ +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 ScadaLink.ClusterInfrastructure; +using ScadaLink.Communication; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Interfaces.Services; +using ScadaLink.ConfigurationDatabase; +using ScadaLink.DataConnectionLayer; +using ScadaLink.DeploymentManager; +using ScadaLink.ExternalSystemGateway; +using ScadaLink.HealthMonitoring; +using ScadaLink.Host; +using ScadaLink.Host.Actors; +using ScadaLink.InboundAPI; +using ScadaLink.ManagementService; +using ScadaLink.NotificationService; +using ScadaLink.Security; +using ScadaLink.SiteEventLogging; +using ScadaLink.SiteRuntime; +using ScadaLink.SiteRuntime.Persistence; +using ScadaLink.SiteRuntime.Repositories; +using ScadaLink.SiteRuntime.Scripts; +using ScadaLink.StoreAndForward; +using ScadaLink.TemplateEngine; +using ScadaLink.TemplateEngine.Flattening; +using ScadaLink.TemplateEngine.Services; +using ScadaLink.TemplateEngine.Validation; + +namespace ScadaLink.Host.Tests; + +/// +/// Removes AkkaHostedService from running as a hosted service while keeping it +/// resolvable in DI (other services like AkkaHealthReportTransport depend on it). +/// +internal static class AkkaHostedServiceRemover +{ + internal static void RemoveAkkaHostedServiceOnly(IServiceCollection services) + { + // Pattern used in Program.cs: + // services.AddSingleton(); // index N + // services.AddHostedService(sp => sp.GetRequiredService()); // 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; + } + } + } +} + +/// +/// Verifies every expected DI service resolves from the Central composition root. +/// Uses WebApplicationFactory to exercise the real Program.cs pipeline. +/// +public class CentralCompositionRootTests : IDisposable +{ + private readonly WebApplicationFactory _factory; + private readonly string? _previousEnv; + + public CentralCompositionRootTests() + { + _previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); + Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central"); + + _factory = new WebApplicationFactory() + .WithWebHostBuilder(builder => + { + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["ScadaLink:Node:NodeHostname"] = "localhost", + ["ScadaLink:Node:RemotingPort"] = "0", + ["ScadaLink:Cluster:SeedNodes:0"] = "akka.tcp://scadalink@localhost:2551", + ["ScadaLink:Cluster:SeedNodes:1"] = "akka.tcp://scadalink@localhost:2552", + ["ScadaLink:Database:SkipMigrations"] = "true", + ["ScadaLink:Security:JwtSigningKey"] = "test-signing-key-must-be-at-least-32-chars-long!", + ["ScadaLink:Security:LdapServer"] = "localhost", + ["ScadaLink:Security:LdapPort"] = "3893", + ["ScadaLink:Security:LdapUseTls"] = "false", + ["ScadaLink:Security:AllowInsecureLdap"] = "true", + ["ScadaLink:Security:LdapSearchBase"] = "dc=scadalink,dc=local", + }); + }); + builder.UseSetting("ScadaLink:Node:Role", "Central"); + builder.UseSetting("ScadaLink: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(ScadaLinkDbContext) || + d.ServiceType.FullName?.Contains("EntityFrameworkCore") == true) + .ToList(); + foreach (var d in descriptorsToRemove) + services.Remove(d); + + services.AddDbContext(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 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 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 + new object[] { typeof(NotificationDeliveryService) }, + new object[] { typeof(INotificationDeliveryService) }, + // ConfigurationDatabase repositories + new object[] { typeof(ScadaLinkDbContext) }, + 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 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 CentralOptions => new[] + { + new object[] { typeof(IOptions) }, + new object[] { typeof(IOptions) }, + new object[] { typeof(IOptions) }, + new object[] { typeof(IOptions) }, + new object[] { typeof(IOptions) }, + new object[] { typeof(IOptions) }, + new object[] { typeof(IOptions) }, + new object[] { typeof(IOptions) }, + new object[] { typeof(IOptions) }, + new object[] { typeof(IOptions) }, + new object[] { typeof(IOptions) }, + }; + + // --- Hosted services --- + + [Fact] + public void Central_CentralHealthAggregator_RegisteredAsHostedService() + { + var hostedServices = _factory.Services.GetServices(); + Assert.Contains(hostedServices, s => s.GetType() == typeof(CentralHealthAggregator)); + } +} + +/// +/// 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. +/// +public class SiteCompositionRootTests : IDisposable +{ + private readonly IHost _host; + private readonly string _tempDbPath; + + public SiteCompositionRootTests() + { + _tempDbPath = Path.Combine(Path.GetTempPath(), $"scadalink_test_{Guid.NewGuid()}.db"); + + var builder = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder(); + builder.ConfigureAppConfiguration(config => + { + config.Sources.Clear(); + config.AddInMemoryCollection(new Dictionary + { + ["ScadaLink:Node:Role"] = "Site", + ["ScadaLink:Node:NodeHostname"] = "test-site", + ["ScadaLink:Node:SiteId"] = "TestSite", + ["ScadaLink:Node:RemotingPort"] = "0", + ["ScadaLink:Database:SiteDbPath"] = _tempDbPath, + }); + }); + builder.ConfigureServices((context, services) => + { + SiteServiceRegistration.Configure(services, context.Configuration); + + // Keep AkkaHostedService in DI (other services depend on it) + // but prevent it from starting by removing only its IHostedService registration. + AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(services); + }); + + _host = builder.Build(); + } + + public void Dispose() + { + _host.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 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(IDataConnectionFactory) }, + new object[] { typeof(StoreAndForwardStorage) }, + new object[] { typeof(StoreAndForwardService) }, + new object[] { typeof(ReplicationService) }, + new object[] { typeof(ISiteEventLogger) }, + new object[] { typeof(IEventLogQueryService) }, + new object[] { typeof(OAuth2TokenService) }, + 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 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) }, + new object[] { typeof(NotificationDeliveryService) }, + new object[] { typeof(INotificationDeliveryService) }, + }; + + // --- Implementation type assertions --- + + [Fact] + public void Site_ExternalSystemRepository_IsSiteImplementation() + { + using var scope = _host.Services.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + Assert.IsType(repo); + } + + [Fact] + public void Site_NotificationRepository_IsSiteImplementation() + { + using var scope = _host.Services.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + Assert.IsType(repo); + } + + // --- Options --- + + [Theory] + [MemberData(nameof(SiteOptions))] + public void Site_ResolveOptions(Type optionsType) + { + var service = _host.Services.GetService(optionsType); + Assert.NotNull(service); + } + + public static IEnumerable SiteOptions => new[] + { + new object[] { typeof(IOptions) }, + new object[] { typeof(IOptions) }, + new object[] { typeof(IOptions) }, + new object[] { typeof(IOptions) }, + new object[] { typeof(IOptions) }, + new object[] { typeof(IOptions) }, + new object[] { typeof(IOptions) }, + new object[] { typeof(IOptions) }, + new object[] { typeof(IOptions) }, + new object[] { typeof(IOptions) }, + new object[] { typeof(IOptions) }, + }; + + // --- Hosted services --- + + [Theory] + [MemberData(nameof(SiteHostedServices))] + public void Site_HostedServiceRegistered(Type expectedType) + { + var hostedServices = _host.Services.GetServices(); + Assert.Contains(hostedServices, s => s.GetType() == expectedType); + } + + public static IEnumerable SiteHostedServices => new[] + { + new object[] { typeof(HealthReportSender) }, + new object[] { typeof(SiteStorageInitializer) }, + new object[] { typeof(EventLogPurgeService) }, + }; +} diff --git a/tests/ScadaLink.Host.Tests/HostStartupTests.cs b/tests/ScadaLink.Host.Tests/HostStartupTests.cs index 0418cb8..3b2fa23 100644 --- a/tests/ScadaLink.Host.Tests/HostStartupTests.cs +++ b/tests/ScadaLink.Host.Tests/HostStartupTests.cs @@ -3,16 +3,7 @@ using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using ScadaLink.ClusterInfrastructure; -using ScadaLink.Communication; -using ScadaLink.DataConnectionLayer; -using ScadaLink.ExternalSystemGateway; -using ScadaLink.HealthMonitoring; using ScadaLink.Host; -using ScadaLink.NotificationService; -using ScadaLink.SiteEventLogging; -using ScadaLink.SiteRuntime; -using ScadaLink.StoreAndForward; namespace ScadaLink.Host.Tests; @@ -83,35 +74,12 @@ public class HostStartupTests : IDisposable ["ScadaLink:Node:Role"] = "Site", ["ScadaLink:Node:NodeHostname"] = "test-site", ["ScadaLink:Node:SiteId"] = "TestSite", - ["ScadaLink:Node:RemotingPort"] = "8082", + ["ScadaLink:Node:RemotingPort"] = "0", }); }); builder.ConfigureServices((context, services) => { - // Shared components - services.AddClusterInfrastructure(); - services.AddCommunication(); - services.AddHealthMonitoring(); - services.AddExternalSystemGateway(); - services.AddNotificationService(); - - // Site-only components - services.AddSiteRuntime(); - services.AddDataConnectionLayer(); - services.AddStoreAndForward(); - services.AddSiteEventLogging(); - - // Options binding (mirrors Program.cs site path) - services.Configure(context.Configuration.GetSection("ScadaLink:Node")); - services.Configure(context.Configuration.GetSection("ScadaLink:Cluster")); - services.Configure(context.Configuration.GetSection("ScadaLink:Database")); - services.Configure(context.Configuration.GetSection("ScadaLink:Communication")); - services.Configure(context.Configuration.GetSection("ScadaLink:HealthMonitoring")); - services.Configure(context.Configuration.GetSection("ScadaLink:Notification")); - services.Configure(context.Configuration.GetSection("ScadaLink:Logging")); - services.Configure(context.Configuration.GetSection("ScadaLink:DataConnection")); - services.Configure(context.Configuration.GetSection("ScadaLink:StoreAndForward")); - services.Configure(context.Configuration.GetSection("ScadaLink:SiteEventLog")); + SiteServiceRegistration.Configure(services, context.Configuration); }); var host = builder.Build(); @@ -134,20 +102,12 @@ public class HostStartupTests : IDisposable ["ScadaLink:Node:Role"] = "Site", ["ScadaLink:Node:NodeHostname"] = "test-site", ["ScadaLink:Node:SiteId"] = "TestSite", - ["ScadaLink:Node:RemotingPort"] = "8082", + ["ScadaLink:Node:RemotingPort"] = "0", }); }); builder.ConfigureServices((context, services) => { - services.AddClusterInfrastructure(); - services.AddCommunication(); - services.AddHealthMonitoring(); - services.AddExternalSystemGateway(); - services.AddNotificationService(); - services.AddSiteRuntime(); - services.AddDataConnectionLayer(); - services.AddStoreAndForward(); - services.AddSiteEventLogging(); + SiteServiceRegistration.Configure(services, context.Configuration); }); var host = builder.Build(); diff --git a/tests/ScadaLink.Host.Tests/ScadaLink.Host.Tests.csproj b/tests/ScadaLink.Host.Tests/ScadaLink.Host.Tests.csproj index 11e3c0e..3b73f4e 100644 --- a/tests/ScadaLink.Host.Tests/ScadaLink.Host.Tests.csproj +++ b/tests/ScadaLink.Host.Tests/ScadaLink.Host.Tests.csproj @@ -16,6 +16,7 @@ +