bee295d3ee
Add WaitForAttribute(attributeName, targetValue, timeout, cancellationToken)
to InboundScriptHost.RouteTarget and SandboxInboundScriptHost.RouteTarget,
mirroring the shipped runtime signature in RouteHelper. Eliminates the false
CS error the editor raised against valid Route.To("X").WaitForAttribute(...)
calls in inbound API method scripts. Test asserts the call diagnoses clean
under ScriptKind.InboundApi.
664 lines
27 KiB
C#
664 lines
27 KiB
C#
using Microsoft.Extensions.Caching.Memory;
|
|
using NSubstitute;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.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.Reflection")]
|
|
[InlineData("System.Net")]
|
|
[InlineData("System.Threading")]
|
|
public void ForbiddenUsing_AllBannedNamespaces(string ns)
|
|
{
|
|
// M3.5: the editor deny-list is sourced from the shared ScriptTrustPolicy.
|
|
// System.Threading is now a forbidden root (only Tasks +
|
|
// CancellationToken(Source) are excepted).
|
|
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 SystemDiagnosticsNamespace_NoLongerWholesaleForbidden()
|
|
{
|
|
// M3.5: the unified policy forbids only System.Diagnostics.Process, not the
|
|
// whole System.Diagnostics namespace — so a bare `using System.Diagnostics;`
|
|
// is no longer flagged (Stopwatch & friends are allowed).
|
|
var resp = _svc.Diagnose(new DiagnoseRequest("using System.Diagnostics;"));
|
|
Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA001");
|
|
}
|
|
|
|
[Fact]
|
|
public void StopwatchUsage_IsAllowedUnderUnifiedPolicy()
|
|
{
|
|
// System.Diagnostics.Stopwatch is NOT under the forbidden
|
|
// System.Diagnostics.Process scope — it must not be flagged.
|
|
var resp = _svc.Diagnose(new DiagnoseRequest(
|
|
"using System.Diagnostics; var sw = Stopwatch.StartNew(); return sw.ElapsedMilliseconds;"));
|
|
Assert.DoesNotContain(resp.Markers, m => m.Code is "SCADA001" or "SCADA002");
|
|
}
|
|
|
|
[Fact]
|
|
public void ProcessUsage_IsForbiddenUnderUnifiedPolicy()
|
|
{
|
|
// System.Diagnostics.Process IS the forbidden scope — flagged via the
|
|
// semantic marker even though the parent namespace is allowed.
|
|
var resp = _svc.Diagnose(new DiagnoseRequest(
|
|
"using System.Diagnostics; var p = Process.GetCurrentProcess(); return p.Id;"));
|
|
Assert.Contains(resp.Markers, m => m.Code == "SCADA002" && m.Message.Contains("Process"));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Monitor")]
|
|
[InlineData("Interlocked")]
|
|
[InlineData("Mutex")]
|
|
public void ThreadingPrimitives_NowFlagged_UnderUnifiedPolicy(string typeName)
|
|
{
|
|
// CentralUI's old deny-list allowed most of System.Threading (only Thread +
|
|
// Tasks.Sources were blocked). The unified policy forbids ALL of
|
|
// System.Threading except Tasks + CancellationToken(Source), so these
|
|
// previously-clean primitives are now flagged by the editor.
|
|
var resp = _svc.Diagnose(new DiagnoseRequest(
|
|
$"using System.Threading; var x = typeof({typeName}); return 1;"));
|
|
Assert.Contains(resp.Markers, m => m.Code is "SCADA001" or "SCADA002");
|
|
}
|
|
|
|
[Fact]
|
|
public void CancellationToken_StaysAllowed_UnderUnifiedPolicy()
|
|
{
|
|
// The AllowedExceptions carve-out keeps CancellationToken(Source) clean even
|
|
// though the rest of System.Threading is forbidden.
|
|
var resp = _svc.Diagnose(new DiagnoseRequest(
|
|
"var cts = new System.Threading.CancellationTokenSource(); return cts.Token.IsCancellationRequested;"));
|
|
Assert.DoesNotContain(resp.Markers, m => m.Code is "SCADA001" or "SCADA002");
|
|
}
|
|
|
|
[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()));
|
|
}
|
|
}
|
|
|
|
// ── Inbound-script analysis surface ──────────────────────────────────
|
|
|
|
[Fact]
|
|
public void InboundScript_WaitForAttribute_DiagnosesClean()
|
|
{
|
|
// WaitForAttribute is a shipped inbound-script helper. The editor must
|
|
// not flag it as an error: the InboundScriptHost mirror must expose the
|
|
// method so Roslyn resolves it during static analysis.
|
|
var resp = _svc.Diagnose(new DiagnoseRequest(
|
|
Code: "var ok = await Route.To(\"site-a\").WaitForAttribute(\"Flag\", true, System.TimeSpan.FromSeconds(5));",
|
|
Kind: ScriptKind.InboundApi));
|
|
|
|
Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("CS"));
|
|
Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("SCADA"));
|
|
}
|
|
}
|