Files
scadalink-design/tests/ScadaLink.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs
Joseph Doherty c8b5871782 fix(notification-outbox): re-align Central UI sandbox Notify API with production
The script-analysis sandbox Notify surface was stale after the Notification
Outbox change: SandboxNotifyTarget.Send returned Task<NotificationResult> and
there was no Status method, while production NotifyTarget.Send returns
Task<string> (a NotificationId) plus NotifyHelper.Status. A script that
test-ran cleanly in the sandbox would not compile against the real site
runtime.

- Move the NotificationDeliveryStatus record from ScadaLink.SiteRuntime.Scripts
  into ScadaLink.Commons.Messages.Notification so both production and the
  CentralUI sandbox reference the exact same type (CentralUI does not, and
  should not, reference SiteRuntime). Production NotifyHelper.Status is
  otherwise untouched.
- Rewrite SandboxNotifyHelper/SandboxNotifyTarget to be a signature-faithful
  no-op fake: Send returns Task<string> (a fake NotificationId), Status returns
  Task<NotificationDeliveryStatus>. Production now enqueues into the site S&F
  engine, which has no central-side equivalent in the sandbox, so the fake no
  longer carries an INotificationDeliveryService.
- Add script-analysis tests proving a script using the new Notify shape both
  diagnoses clean and runs in the sandbox.
2026-05-19 03:44:34 -04:00

590 lines
23 KiB
C#

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<ISharedScriptCatalog>();
private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions { SizeLimit = 100 });
private readonly IServiceProvider _services = Substitute.For<IServiceProvider>();
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<ScriptShape>());
_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<AttributeShape>(), scripts ?? Array.Empty<ScriptShape>());
[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<string> (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<string> 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<string> 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<Task<string>>();
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()));
}
}
}