using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.Logging.Abstractions; using ScadaLink.Commons.Messages.Deployment; using ScadaLink.Commons.Types.Enums; using ScadaLink.Commons.Types.Flattening; using ScadaLink.SiteRuntime.Actors; using ScadaLink.SiteRuntime.Persistence; using ScadaLink.SiteRuntime.Scripts; using System.Text.Json; namespace ScadaLink.SiteRuntime.Tests.Actors; /// /// Regression tests for the Medium-severity DeploymentManagerActor findings: /// SiteRuntime-005 (Success reported before persistence completes) and /// SiteRuntime-008 (blocking shared-script load on the actor thread). /// public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable { private readonly ScriptCompilationService _compilationService; private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly string _dbFile; public DeploymentManagerMediumFindingsTests() { _dbFile = Path.Combine(Path.GetTempPath(), $"dm-medium-test-{Guid.NewGuid():N}.db"); _compilationService = new ScriptCompilationService( NullLogger.Instance); _sharedScriptLibrary = new SharedScriptLibrary( _compilationService, NullLogger.Instance); } void IDisposable.Dispose() { Shutdown(); try { File.Delete(_dbFile); } catch { /* cleanup */ } } private SiteStorageService NewStorage(string connectionString) => new(connectionString, NullLogger.Instance); private IActorRef CreateDeploymentManager(SiteStorageService storage, IActorRef? dclManager = null) { return ActorOf(Props.Create(() => new DeploymentManagerActor( storage, _compilationService, _sharedScriptLibrary, null, new SiteRuntimeOptions(), NullLogger.Instance, dclManager, null, null, null))); } private static string MakeConfigJsonWithConnection( string instanceName, string endpoint, int failoverRetryCount) { var config = new FlattenedConfiguration { InstanceUniqueName = instanceName, Attributes = [ new ResolvedAttribute { CanonicalName = "TestAttr", Value = "1", DataType = "Int32" } ], Connections = new Dictionary { ["Conn1"] = new ConnectionConfig { Protocol = "Custom", ConfigurationJson = $"{{\"endpoint\":\"{endpoint}\"}}", FailoverRetryCount = failoverRetryCount } } }; return JsonSerializer.Serialize(config); } private static string MakeConfigJson(string instanceName) { var config = new FlattenedConfiguration { InstanceUniqueName = instanceName, Attributes = [ new ResolvedAttribute { CanonicalName = "TestAttr", Value = "1", DataType = "Int32" } ] }; return JsonSerializer.Serialize(config); } /// /// SiteRuntime-005: when SQLite persistence of the deployed config fails, the /// Deployment Manager must report to central, /// not . Reporting Success on a persistence /// failure silently loses the deployment on the next restart/failover. /// [Fact] public async Task Deploy_PersistenceFailure_ReportsFailedNotSuccess() { // A connection string pointing at an unwritable path makes every storage // write throw, so StoreDeployedConfigAsync fails. var badPath = Path.Combine( Path.GetTempPath(), $"no-such-dir-{Guid.NewGuid():N}", "site.db"); var storage = NewStorage($"Data Source={badPath}"); var actor = CreateDeploymentManager(storage); await Task.Delay(500); // empty startup actor.Tell(new DeployInstanceCommand( "dep-fail", "FailPump", "h1", MakeConfigJson("FailPump"), "admin", DateTimeOffset.UtcNow)); var response = ExpectMsg(TimeSpan.FromSeconds(10)); Assert.Equal("FailPump", response.InstanceUniqueName); Assert.Equal(DeploymentStatus.Failed, response.Status); Assert.False(string.IsNullOrEmpty(response.ErrorMessage)); } /// /// SiteRuntime-005: a successful deployment must still report /// , and only after the config row is /// committed to SQLite (so a restart re-creates the instance). /// [Fact] public async Task Deploy_Success_ReportsSuccessAndPersistsConfig() { var storage = NewStorage($"Data Source={_dbFile}"); await storage.InitializeAsync(); var actor = CreateDeploymentManager(storage); await Task.Delay(500); actor.Tell(new DeployInstanceCommand( "dep-ok", "OkPump", "h1", MakeConfigJson("OkPump"), "admin", DateTimeOffset.UtcNow)); var response = ExpectMsg(TimeSpan.FromSeconds(10)); Assert.Equal(DeploymentStatus.Success, response.Status); // By the time Success is reported, the config must be durable. var configs = await storage.GetAllDeployedConfigsAsync(); Assert.Contains(configs, c => c.InstanceUniqueName == "OkPump"); } /// /// SiteRuntime-010: when a redeployment changes a connection's configuration /// (here the failover retry count and endpoint), the Deployment Manager must /// re-issue a /// so the DCL adopts the new configuration rather than keeping the stale one. /// [Fact] public async Task EnsureDclConnections_ConnectionConfigChanged_ReissuesCreateCommand() { var storage = NewStorage($"Data Source={_dbFile}"); await storage.InitializeAsync(); var dcl = CreateTestProbe(); var actor = CreateDeploymentManager(storage, dcl.Ref); await Task.Delay(500); // Initial deploy with one connection. actor.Tell(new DeployInstanceCommand( "dep-c1", "ConnPump", "h1", MakeConfigJsonWithConnection("ConnPump", "opc.tcp://host-a:4840", 3), "admin", DateTimeOffset.UtcNow)); var firstCreate = dcl.ExpectMsg( TimeSpan.FromSeconds(5)); Assert.Equal("Conn1", firstCreate.ConnectionName); Assert.Equal(3, firstCreate.FailoverRetryCount); ExpectMsg(TimeSpan.FromSeconds(5)); await Task.Delay(500); // Redeploy with a CHANGED connection configuration. actor.Tell(new DeployInstanceCommand( "dep-c2", "ConnPump", "h2", MakeConfigJsonWithConnection("ConnPump", "opc.tcp://host-b:4840", 7), "admin", DateTimeOffset.UtcNow)); // The DCL must receive a fresh create command reflecting the new config. var secondCreate = dcl.ExpectMsg( TimeSpan.FromSeconds(10)); Assert.Equal("Conn1", secondCreate.ConnectionName); Assert.Equal(7, secondCreate.FailoverRetryCount); } /// /// SiteRuntime-010: an unchanged connection configuration must still be skipped — /// re-sending an identical create command on every deploy is wasteful. /// [Fact] public async Task EnsureDclConnections_UnchangedConfig_DoesNotReissueCreateCommand() { var storage = NewStorage($"Data Source={_dbFile}"); await storage.InitializeAsync(); var dcl = CreateTestProbe(); var actor = CreateDeploymentManager(storage, dcl.Ref); await Task.Delay(500); var json = MakeConfigJsonWithConnection("StablePump", "opc.tcp://host-a:4840", 3); actor.Tell(new DeployInstanceCommand( "dep-s1", "StablePump", "h1", json, "admin", DateTimeOffset.UtcNow)); dcl.ExpectMsg( TimeSpan.FromSeconds(5)); ExpectMsg(TimeSpan.FromSeconds(5)); await Task.Delay(500); // Redeploy with the IDENTICAL connection configuration. actor.Tell(new DeployInstanceCommand( "dep-s2", "StablePump", "h2", json, "admin", DateTimeOffset.UtcNow)); ExpectMsg(TimeSpan.FromSeconds(10)); // No further create command for an unchanged connection. dcl.ExpectNoMsg(TimeSpan.FromMilliseconds(800)); } /// /// SiteRuntime-008: startup must not block the Deployment Manager mailbox on a /// synchronous shared-script load. With shared scripts present, the actor must /// still load deployed configs, create Instance Actors, and remain responsive. /// [Fact] public async Task Startup_WithSharedScripts_LoadsConfigsAndStaysResponsive() { var storage = NewStorage($"Data Source={_dbFile}"); await storage.InitializeAsync(); // Several shared scripts to compile during startup. for (var i = 0; i < 5; i++) { await storage.StoreSharedScriptAsync( $"Shared{i}", "return 1 + 1;", null, null); } await storage.StoreDeployedConfigAsync( "StartupPump", MakeConfigJson("StartupPump"), "d1", "h1", true); var actor = CreateDeploymentManager(storage); await Task.Delay(2000); // The instance loaded at startup must be operable — proves startup completed // and the actor processed messages after the shared-script load. actor.Tell(new DeploymentStateQueryRequest("corr-1", "StartupPump", DateTimeOffset.UtcNow)); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.True(response.IsDeployed); } }