Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
183 lines
6.6 KiB
C#
183 lines
6.6 KiB
C#
using Microsoft.CodeAnalysis.Scripting;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
|
|
|
/// <summary>
|
|
/// Compiles scripts against the Phase 7 sandbox + asserts every forbidden API
|
|
/// (HttpClient / File / Process / reflection) fails at compile, not at evaluation.
|
|
/// Locks decision #6 — scripts can't escape to the broader .NET surface.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class ScriptSandboxTests
|
|
{
|
|
[Fact]
|
|
public void Happy_path_script_compiles_and_returns()
|
|
{
|
|
// Baseline — ctx + Math + basic types must work.
|
|
var evaluator = ScriptEvaluator<FakeScriptContext, double>.Compile(
|
|
"""
|
|
var v = (double)ctx.GetTag("X").Value;
|
|
return Math.Abs(v) * 2.0;
|
|
""");
|
|
evaluator.ShouldNotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Happy_path_script_runs_and_reads_seeded_tag()
|
|
{
|
|
var evaluator = ScriptEvaluator<FakeScriptContext, double>.Compile(
|
|
"""return (double)ctx.GetTag("In").Value * 2.0;""");
|
|
|
|
var ctx = new FakeScriptContext().Seed("In", 21.0);
|
|
var result = await evaluator.RunAsync(ctx, TestContext.Current.CancellationToken);
|
|
result.ShouldBe(42.0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SetVirtualTag_records_the_write()
|
|
{
|
|
var evaluator = ScriptEvaluator<FakeScriptContext, int>.Compile(
|
|
"""
|
|
ctx.SetVirtualTag("Out", 42);
|
|
return 0;
|
|
""");
|
|
var ctx = new FakeScriptContext();
|
|
await evaluator.RunAsync(ctx, TestContext.Current.CancellationToken);
|
|
ctx.Writes.Count.ShouldBe(1);
|
|
ctx.Writes[0].Path.ShouldBe("Out");
|
|
ctx.Writes[0].Value.ShouldBe(42);
|
|
}
|
|
|
|
[Fact]
|
|
public void Rejects_File_IO_at_compile()
|
|
{
|
|
Should.Throw<ScriptSandboxViolationException>(() =>
|
|
ScriptEvaluator<FakeScriptContext, string>.Compile(
|
|
"""return System.IO.File.ReadAllText("c:/secrets.txt");"""));
|
|
}
|
|
|
|
[Fact]
|
|
public void Rejects_HttpClient_at_compile()
|
|
{
|
|
Should.Throw<ScriptSandboxViolationException>(() =>
|
|
ScriptEvaluator<FakeScriptContext, int>.Compile(
|
|
"""
|
|
var c = new System.Net.Http.HttpClient();
|
|
return 0;
|
|
"""));
|
|
}
|
|
|
|
[Fact]
|
|
public void Rejects_Process_Start_at_compile()
|
|
{
|
|
Should.Throw<ScriptSandboxViolationException>(() =>
|
|
ScriptEvaluator<FakeScriptContext, int>.Compile(
|
|
"""
|
|
System.Diagnostics.Process.Start("cmd.exe");
|
|
return 0;
|
|
"""));
|
|
}
|
|
|
|
[Fact]
|
|
public void Rejects_Reflection_Assembly_Load_at_compile()
|
|
{
|
|
Should.Throw<ScriptSandboxViolationException>(() =>
|
|
ScriptEvaluator<FakeScriptContext, int>.Compile(
|
|
"""
|
|
System.Reflection.Assembly.Load("System.Core");
|
|
return 0;
|
|
"""));
|
|
}
|
|
|
|
[Fact]
|
|
public void Rejects_Environment_GetEnvironmentVariable_at_compile()
|
|
{
|
|
// Environment lives in System.Private.CoreLib (allow-listed for primitives) —
|
|
// BUT calling .GetEnvironmentVariable exposes process state we don't want in
|
|
// scripts. In an allow-list sandbox this passes because mscorlib is allowed;
|
|
// relying on ScriptSandbox alone isn't enough for the Environment class. We
|
|
// document here that the CURRENT sandbox allows Environment — acceptable because
|
|
// Environment doesn't leak outside the process boundary, doesn't side-effect
|
|
// persistent state, and Phase 7 plan decision #6 targets File/Net/Process/
|
|
// reflection specifically.
|
|
//
|
|
// This test LOCKS that compromise: operators should not be surprised if a
|
|
// script reads an env var. If we later decide to tighten, this test flips.
|
|
var evaluator = ScriptEvaluator<FakeScriptContext, string?>.Compile(
|
|
"""return System.Environment.GetEnvironmentVariable("PATH");""");
|
|
evaluator.ShouldNotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Script_exception_propagates_unwrapped()
|
|
{
|
|
var evaluator = ScriptEvaluator<FakeScriptContext, int>.Compile(
|
|
"""throw new InvalidOperationException("boom");""");
|
|
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
|
await evaluator.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken));
|
|
}
|
|
|
|
[Fact]
|
|
public void Ctx_Now_is_available_without_DateTime_UtcNow_reaching_wall_clock()
|
|
{
|
|
// Scripts that need a timestamp go through ctx.Now so tests can pin it.
|
|
var evaluator = ScriptEvaluator<FakeScriptContext, DateTime>.Compile("""return ctx.Now;""");
|
|
evaluator.ShouldNotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Deadband_helper_is_reachable_from_scripts()
|
|
{
|
|
var evaluator = ScriptEvaluator<FakeScriptContext, bool>.Compile(
|
|
"""return ScriptContext.Deadband(10.5, 10.0, 0.3);""");
|
|
evaluator.ShouldNotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Linq_Enumerable_is_available_from_scripts()
|
|
{
|
|
// LINQ is in the allow-list because SCADA math frequently wants Sum / Average
|
|
// / Where. Confirm it works.
|
|
var evaluator = ScriptEvaluator<FakeScriptContext, int>.Compile(
|
|
"""
|
|
var nums = new[] { 1, 2, 3, 4, 5 };
|
|
return nums.Where(n => n > 2).Sum();
|
|
""");
|
|
var result = await evaluator.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken);
|
|
result.ShouldBe(12);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DataValueSnapshot_is_usable_in_scripts()
|
|
{
|
|
// ctx.GetTag returns DataValueSnapshot so scripts branch on quality.
|
|
var evaluator = ScriptEvaluator<FakeScriptContext, bool>.Compile(
|
|
"""
|
|
var v = ctx.GetTag("T");
|
|
return v.StatusCode == 0;
|
|
""");
|
|
var ctx = new FakeScriptContext().Seed("T", 5.0);
|
|
var result = await evaluator.RunAsync(ctx, TestContext.Current.CancellationToken);
|
|
result.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Compile_error_gives_location_in_diagnostics()
|
|
{
|
|
// Compile errors must carry the source span so the Admin UI can point at them.
|
|
try
|
|
{
|
|
ScriptEvaluator<FakeScriptContext, int>.Compile("""return fooBarBaz + 1;""");
|
|
Assert.Fail("expected CompilationErrorException");
|
|
}
|
|
catch (CompilationErrorException ex)
|
|
{
|
|
ex.Diagnostics.ShouldNotBeEmpty();
|
|
ex.Diagnostics[0].Location.ShouldNotBeNull();
|
|
}
|
|
}
|
|
}
|