using Microsoft.Extensions.Caching.Memory; using NSubstitute; using ScadaLink.CentralUI.ScriptAnalysis; namespace ScadaLink.CentralUI.Tests.ScriptAnalysis; public class ScriptAnalysisServiceTests { private readonly ISharedScriptCatalog _catalog = Substitute.For(); private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions { SizeLimit = 100 }); private readonly IServiceProvider _services = Substitute.For(); private readonly ScriptAnalysisService _svc; private static ScriptShape Shape(string name, params ParameterShape[] ps) => new(name, ps, null); private static ParameterShape Param(string name, string type = "String", bool required = true) => new(name, type, required); public ScriptAnalysisServiceTests() { _catalog.GetShapesAsync().Returns(Array.Empty()); _svc = new ScriptAnalysisService(_catalog, _cache, _services); } // ── Diagnose ────────────────────────────────────────────────────────── [Fact] public void EmptyCode_NoMarkers() { var resp = _svc.Diagnose(new DiagnoseRequest("")); Assert.Empty(resp.Markers); } [Fact] public void CleanScript_NoMarkers() { var resp = _svc.Diagnose(new DiagnoseRequest("var x = 1 + 2; return x;")); Assert.Empty(resp.Markers); } [Fact] public void MissingSemicolon_ReportsRoslynDiagnostic() { var resp = _svc.Diagnose(new DiagnoseRequest("var x = 1\n")); Assert.Contains(resp.Markers, m => m.Code.StartsWith("CS")); } [Fact] public void ForbiddenUsingDirective_RaisesSCADA001() { var resp = _svc.Diagnose(new DiagnoseRequest("using System.IO;")); Assert.Contains(resp.Markers, m => m.Code == "SCADA001" && m.Message.Contains("System.IO")); } [Theory] [InlineData("System.Diagnostics")] [InlineData("System.Reflection")] [InlineData("System.Net")] public void ForbiddenUsing_AllBannedNamespaces(string ns) { var resp = _svc.Diagnose(new DiagnoseRequest($"using {ns};")); Assert.Contains(resp.Markers, m => m.Code == "SCADA001" && m.Message.Contains(ns)); } [Fact] public void ForbiddenTypeUsage_ResolvesViaSemanticModel() { var resp = _svc.Diagnose(new DiagnoseRequest( "using System.IO; var s = File.ReadAllText(\"x\");")); Assert.Contains(resp.Markers, m => m.Code == "SCADA002" && m.Message.Contains("File")); } [Fact] public void UserIdentifierNamedFile_DoesNotFalsePositive() { var resp = _svc.Diagnose(new DiagnoseRequest( "var File = \"hello\"; return File.Length;")); Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA002"); } [Fact] public void UserIdentifierNamedThread_DoesNotFalsePositive() { var resp = _svc.Diagnose(new DiagnoseRequest( "var Thread = 42; return Thread;")); Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA002"); } [Fact] public void DiagnosticsAreCached_SecondCallSkipsRecompile() { var req = new DiagnoseRequest("using System.IO;"); var first = _svc.Diagnose(req); var second = _svc.Diagnose(req); Assert.Same(first, second); } [Fact] public void DifferentCode_GetsDifferentCacheEntries() { var a = _svc.Diagnose(new DiagnoseRequest("var x = 1;")); var b = _svc.Diagnose(new DiagnoseRequest("var y = 2;")); Assert.NotSame(a, b); } [Fact] public void UnknownParameterKey_RaisesSCADA003() { var resp = _svc.Diagnose(new DiagnoseRequest( Code: "var x = Parameters[\"typo\"];", DeclaredParameters: new[] { "name", "temperature" })); Assert.Contains(resp.Markers, m => m.Code == "SCADA003" && m.Message.Contains("'typo'")); } [Fact] public void DeclaredParameterKey_NoMarker() { var resp = _svc.Diagnose(new DiagnoseRequest( Code: "var x = Parameters[\"name\"];", DeclaredParameters: new[] { "name", "temperature" })); Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA003"); } // ── Completions ─────────────────────────────────────────────────────── [Fact] public async Task ParametersStringLiteral_ReturnsDeclaredParameterNames() { var req = new CompletionsRequest( CodeText: "var x = Parameters[\"", Line: 1, Column: 21, DeclaredParameters: new[] { "name", "temperature" }); var resp = await _svc.CompleteAsync(req); Assert.Contains(resp.Items, i => i.Label == "name" && i.Detail == "declared parameter"); Assert.Contains(resp.Items, i => i.Label == "temperature"); } [Fact] public async Task CallScriptStringLiteral_ReturnsSiblingNamesWithSnippet() { var siblings = new[] { Shape("SiblingA", Param("x")) }; var req = new CompletionsRequest( CodeText: "var x = Instance.CallScript(\"", Line: 1, Column: 30, SiblingScripts: siblings); var resp = await _svc.CompleteAsync(req); var item = Assert.Single(resp.Items, i => i.Label == "SiblingA"); Assert.Equal(4, item.InsertTextRules); // The runtime call API takes args as an anonymous object — the snippet // emits one member per declared parameter. Assert.Contains("new { x = ${1:x} }", item.InsertText); Assert.Contains("sibling script", item.Detail); } [Fact] public async Task CallSharedStringLiteral_ResolvesViaCatalogWithShapes() { _catalog.GetShapesAsync().Returns(new[] { Shape("GetWeather"), Shape("Greet", Param("name")) }); var req = new CompletionsRequest( CodeText: "var x = Scripts.CallShared(\"", Line: 1, Column: 29); var resp = await _svc.CompleteAsync(req); // No-parameter shape: snippet just closes the call. var weather = Assert.Single(resp.Items, i => i.Label == "GetWeather"); Assert.Equal("GetWeather\")", weather.InsertText); // Parameterized shape: anonymous-object member per parameter. var greet = Assert.Single(resp.Items, i => i.Label == "Greet"); Assert.Contains("new { name = ${1:name} }", greet.InsertText); Assert.Contains("shared script", greet.Detail); } [Fact] public async Task GeneralCompletion_ReturnsInScopeSymbols() { var req = new CompletionsRequest("var x = ", 1, 9); var resp = await _svc.CompleteAsync(req); // SandboxScriptHost globals are surfaced as in-scope symbols. The // runtime call API is member-access — Scripts.CallShared / Instance.* // — so the top-level globals are Parameters, Scripts, and Instance. Assert.Contains(resp.Items, i => i.Label == "Parameters"); Assert.Contains(resp.Items, i => i.Label == "Scripts"); Assert.Contains(resp.Items, i => i.Label == "Instance"); } // ── Hover ───────────────────────────────────────────────────────────── [Fact] public async Task Hover_OnSiblingName_ReturnsSignature() { var siblings = new[] { Shape("Calc", Param("x", "Integer"), Param("y", "Float")) }; // Cursor inside the "Calc" name literal of Instance.CallScript("Calc", ...). var resp = await _svc.Hover(new HoverRequest( CodeText: "var r = Instance.CallScript(\"Calc\", 1, 2);", Line: 1, Column: 32, SiblingScripts: siblings)); Assert.NotNull(resp.Markdown); Assert.Contains("sibling script", resp.Markdown); Assert.Contains("Calc(x: Integer, y: Float): void", resp.Markdown); } [Fact] public async Task Hover_OnUnrelatedToken_ReturnsNull() { var resp = await _svc.Hover(new HoverRequest( CodeText: "var r = 1 + 2;", Line: 1, Column: 5)); Assert.Null(resp.Markdown); } // ── Signature help ──────────────────────────────────────────────────── [Fact] public async Task SignatureHelp_InsideCallScript_ReturnsParameterStrip() { var siblings = new[] { Shape("Calc", Param("x", "Integer"), Param("y", "Float")) }; var resp = await _svc.SignatureHelp(new SignatureHelpRequest( CodeText: "var r = Instance.CallScript(\"Calc\", 1, ", Line: 1, Column: 40, SiblingScripts: siblings)); Assert.Equal("Instance.CallScript(\"Calc\", x: Integer, y: Float)", resp.Label); Assert.Equal(2, resp.Parameters!.Count); Assert.Equal("x: Integer", resp.Parameters[0].Label); Assert.Equal("y: Float", resp.Parameters[1].Label); Assert.Equal(1, resp.ActiveParameter); } [Fact] public async Task SignatureHelp_OutsideCall_ReturnsNull() { var resp = await _svc.SignatureHelp(new SignatureHelpRequest( CodeText: "var r = 1 + 2;", Line: 1, Column: 5)); Assert.Null(resp.Label); } // ── Format ──────────────────────────────────────────────────────────── [Fact] public void Format_ScrambledCode_ReturnsPrettyPrinted() { var resp = _svc.Format(new FormatRequest("if(x){return 1;}else{return 2;}")); // Roslyn's default formatter adds spaces around keywords/braces. Assert.Contains("if (x)", resp.Code); Assert.NotEqual("if(x){return 1;}else{return 2;}", resp.Code); } [Fact] public void Format_EmptyCode_ReturnsEmpty() { Assert.Equal("", _svc.Format(new FormatRequest("")).Code); } // ── Self / Children / Parent attribute completions ──────────────────── private static AttributeShape Attr(string name, string type = "String") => new(name, type); private static CompositionContext Comp(string name, AttributeShape[]? attrs = null, ScriptShape[]? scripts = null) => new(name, attrs ?? Array.Empty(), scripts ?? Array.Empty()); [Fact] public async Task SelfAttribute_Literal_ReturnsSelfAttributeNames() { var req = new CompletionsRequest( CodeText: "var x = Attributes[\"", Line: 1, Column: 21, SelfAttributes: new[] { Attr("Temperature"), Attr("Setpoint", "Float") }); var resp = await _svc.CompleteAsync(req); Assert.Contains(resp.Items, i => i.Label == "Temperature"); Assert.Contains(resp.Items, i => i.Label == "Setpoint" && i.Detail.Contains("Float")); } [Fact] public async Task ChildAttribute_Literal_ReturnsChildAttributeNames() { var req = new CompletionsRequest( CodeText: "var x = Children[\"TempSensor\"].Attributes[\"", Line: 1, Column: 44, Children: new[] { Comp("TempSensor", attrs: new[] { Attr("Temperature"), Attr("Humidity") }) }); var resp = await _svc.CompleteAsync(req); Assert.Contains(resp.Items, i => i.Label == "Temperature"); Assert.Contains(resp.Items, i => i.Label == "Humidity"); } [Fact] public async Task ParentAttribute_Literal_ReturnsParentAttributeNames() { var req = new CompletionsRequest( CodeText: "var x = Parent.Attributes[\"", Line: 1, Column: 28, Parent: Comp("Motor", attrs: new[] { Attr("SpeedRPM") })); var resp = await _svc.CompleteAsync(req); Assert.Contains(resp.Items, i => i.Label == "SpeedRPM"); } [Fact] public async Task ChildrenLiteral_ReturnsCompositionNames() { var req = new CompletionsRequest( CodeText: "var x = Children[\"", Line: 1, Column: 19, Children: new[] { Comp("TempSensor"), Comp("PressureSensor") }); var resp = await _svc.CompleteAsync(req); Assert.Contains(resp.Items, i => i.Label == "TempSensor" && i.Detail == "composition"); Assert.Contains(resp.Items, i => i.Label == "PressureSensor"); } [Fact] public void UnknownSelfAttribute_RaisesSCADA006() { var resp = _svc.Diagnose(new DiagnoseRequest( Code: "var x = Attributes[\"Typo\"];", SelfAttributes: new[] { Attr("Temperature") })); Assert.Contains(resp.Markers, m => m.Code == "SCADA006" && m.Message.Contains("Typo")); } [Fact] public void KnownSelfAttribute_NoMarker() { var resp = _svc.Diagnose(new DiagnoseRequest( Code: "var x = Attributes[\"Temperature\"];", SelfAttributes: new[] { Attr("Temperature") })); Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA006"); } [Fact] public void UnknownChildAttribute_RaisesSCADA006() { var resp = _svc.Diagnose(new DiagnoseRequest( Code: "var x = Children[\"TempSensor\"].Attributes[\"Typo\"];", Children: new[] { Comp("TempSensor", attrs: new[] { Attr("Temperature") }) })); Assert.Contains(resp.Markers, m => m.Code == "SCADA006" && m.Message.Contains("TempSensor")); } [Fact] public void UnknownComposition_RaisesSCADA007() { var resp = _svc.Diagnose(new DiagnoseRequest( Code: "var x = Children[\"Unknown\"].Attributes[\"X\"];", Children: new[] { Comp("TempSensor") })); Assert.Contains(resp.Markers, m => m.Code == "SCADA007" && m.Message.Contains("Unknown")); } [Fact] public async Task ChildrenCallScript_ReturnsChildScripts() { var req = new CompletionsRequest( CodeText: "var x = Children[\"TempSensor\"].CallScript(\"", Line: 1, Column: 44, Children: new[] { Comp("TempSensor", scripts: new[] { Shape("Sample", Param("count", "Integer")) }) }); var resp = await _svc.CompleteAsync(req); var sample = Assert.Single(resp.Items, i => i.Label == "Sample"); Assert.Contains("script on TempSensor", sample.Detail); Assert.Contains("${1:count}", sample.InsertText); } [Fact] public async Task ParentCallScript_ReturnsParentScripts() { var req = new CompletionsRequest( CodeText: "var x = Parent.CallScript(\"", Line: 1, Column: 28, Parent: Comp("Motor", scripts: new[] { Shape("Trip") })); var resp = await _svc.CompleteAsync(req); Assert.Contains(resp.Items, i => i.Label == "Trip" && i.Detail.Contains("parent script")); } // ── Hover on Parameters["name"] ─────────────────────────────────────── [Fact] public async Task Hover_OnParametersKey_ShowsDeclaredType() { var resp = await _svc.Hover(new HoverRequest( CodeText: "var x = Parameters[\"name\"];", Line: 1, Column: 22, DeclaredParameters: new[] { new ParameterShape("name", "String", true) })); Assert.NotNull(resp.Markdown); Assert.Contains("name", resp.Markdown); Assert.Contains("String", resp.Markdown); } // ── CentralUI-001: trust-model gate before sandbox execution ────────── [Fact] public void Diagnose_FullyQualifiedForbiddenCall_RaisesSCADA002() { // A forbidden API reached by fully-qualified name (no `using`, no bare // type identifier) must still be flagged — the pre-fix semantic check // only inspected the leftmost identifier and missed this shape. var resp = _svc.Diagnose(new DiagnoseRequest( "var d = System.IO.Directory.GetCurrentDirectory(); return d;")); Assert.Contains(resp.Markers, m => m.Code == "SCADA002"); } [Fact] public async Task RunInSandbox_FullyQualifiedForbiddenApi_IsBlockedBeforeExecution() { // Regression test for CentralUI-001. RunInSandboxAsync used to execute any // script that compiled, with no trust-model enforcement — so fully-qualified // forbidden API code ran in the central host process. The fix gates execution // on the forbidden-API analysis. var result = await _svc.RunInSandboxAsync( new SandboxRunRequest( "var d = System.IO.Directory.GetCurrentDirectory(); return d;", Parameters: null, TimeoutSeconds: null), CancellationToken.None); Assert.False(result.Success); Assert.Equal(SandboxErrorKind.CompileError, result.ErrorKind); Assert.Contains("trust model", result.Error); Assert.NotNull(result.Markers); Assert.Contains(result.Markers!, m => m.Code is "SCADA001" or "SCADA002"); } [Fact] public async Task RunInSandbox_ForbiddenUsingDirective_IsBlockedBeforeExecution() { var result = await _svc.RunInSandboxAsync( new SandboxRunRequest( "using System.Diagnostics; var p = Process.GetCurrentProcess().Id; return p;", Parameters: null, TimeoutSeconds: null), CancellationToken.None); Assert.False(result.Success); Assert.Equal(SandboxErrorKind.CompileError, result.ErrorKind); } [Fact] public async Task RunInSandbox_CleanScript_StillRuns() { // The gate must not block a script that stays within the allowed surface. var result = await _svc.RunInSandboxAsync( new SandboxRunRequest("return 21 * 2;", Parameters: null, TimeoutSeconds: null), CancellationToken.None); Assert.True(result.Success); Assert.Equal("42", result.ReturnValueJson); } [Fact] public void NotifyOutboxShape_DiagnosesClean() { // Notification Outbox: the sandbox Notify surface must be // signature-faithful to production NotifyHelper/NotifyTarget — // Send returns Task (a NotificationId) and Status takes that // id. A script using the new shape must compile clean in the sandbox, // exactly as it would against the real site runtime. var resp = _svc.Diagnose(new DiagnoseRequest( "var id = await Notify.To(\"ops\").Send(\"subj\", \"body\"); " + "var st = await Notify.Status(id); " + "return st.Status;")); Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("CS")); Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("SCADA")); } [Fact] public async Task RunInSandbox_NotifyOutboxShape_StillRuns() { // The new Notify shape must also run end-to-end in the no-op sandbox: // Send yields a fake NotificationId, Status yields a placeholder // NotificationDeliveryStatus. var result = await _svc.RunInSandboxAsync( new SandboxRunRequest( "var id = await Notify.To(\"ops\").Send(\"subj\", \"body\"); " + "var st = await Notify.Status(id); " + "return st.Status;", Parameters: null, TimeoutSeconds: null), CancellationToken.None); Assert.True(result.Success); Assert.Equal("\"Unknown\"", result.ReturnValueJson); } [Fact] public async Task RunInSandbox_CapturesConsoleOutput() { var result = await _svc.RunInSandboxAsync( new SandboxRunRequest( "System.Console.WriteLine(\"hello-sandbox\"); return 1;", Parameters: null, TimeoutSeconds: null), CancellationToken.None); Assert.True(result.Success); Assert.Contains("hello-sandbox", result.ConsoleOutput); } [Fact] public async Task RunInSandbox_ConcurrentRuns_DoNotCrossContaminateConsoleOutput() { // Regression test for CentralUI-003. RunInSandboxAsync used to redirect the // process-global Console.Out/Error to a per-call StringWriter. While one run // is mid-flight, any concurrent run's `finally` restores Console.Out to the // ORIGINAL writer — so the long run loses every Console.WriteLine it makes // after that point, and short runs cross-contaminate each other. The fix // routes capture per-call via an AsyncLocal writer without mutating // process-global Console state. // A long-running script: writes its tag, then burns CPU, then writes again, // repeatedly. While it spins, many short runs start and finish around it. async Task RunLong() { var code = @" for (int i = 0; i < 40; i++) { System.Console.WriteLine(""LONG""); long acc = 0; for (long j = 0; j < 2_000_000; j++) acc += j; System.Console.WriteLine(""LONG"" + acc); } return 0;"; var r = await _svc.RunInSandboxAsync( new SandboxRunRequest(code, Parameters: null, TimeoutSeconds: 30), CancellationToken.None); Assert.True(r.Success, r.Error); return r.ConsoleOutput; } async Task RunShort(int id) { var code = $"for (int i = 0; i < 30; i++) System.Console.WriteLine(\"S{id}\"); return 0;"; var r = await _svc.RunInSandboxAsync( new SandboxRunRequest(code, Parameters: null, TimeoutSeconds: 30), CancellationToken.None); Assert.True(r.Success, r.Error); return r.ConsoleOutput; } var longTask = RunLong(); var shortTasks = new List>(); for (var round = 0; round < 12; round++) { for (var k = 0; k < 4; k++) shortTasks.Add(RunShort(round * 4 + k)); await Task.Yield(); } var longOut = await longTask; var shortOuts = await Task.WhenAll(shortTasks); // The long run must have captured ALL 80 of its own writes (40 plain + 40 acc). var longLines = longOut.Split('\n', StringSplitOptions.RemoveEmptyEntries) .Count(l => l.StartsWith("LONG")); Assert.Equal(80, longLines); // No short run's output must have leaked into the long run's capture. for (var i = 0; i < shortOuts.Length; i++) Assert.DoesNotContain($"S{i}", longOut); // Each short run captured exactly its own 30 lines and nothing else. for (var i = 0; i < shortOuts.Length; i++) { var lines = shortOuts[i].Split('\n', StringSplitOptions.RemoveEmptyEntries); Assert.Equal(30, lines.Length); Assert.All(lines, l => Assert.Equal($"S{i}", l.Trim())); } } }