239 lines
10 KiB
C#
239 lines
10 KiB
C#
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Validation;
|
|
|
|
/// <summary>
|
|
/// M3.2: the design-time deploy gate now delegates to the shared
|
|
/// <c>ZB.MOM.WW.ScadaBridge.ScriptAnalysis</c> analyzer — a real Roslyn semantic
|
|
/// forbidden-API verdict plus a real CSharpScript compile against the
|
|
/// runtime-mirroring <c>ScriptCompileSurface</c>. These tests assert the
|
|
/// authoritative behavior: bypasses the old substring scan missed are now caught,
|
|
/// and undefined symbols (which a structural scan could never see) fail compile.
|
|
/// </summary>
|
|
public class ScriptCompilerTests
|
|
{
|
|
private readonly ScriptCompiler _sut = new();
|
|
|
|
[Fact]
|
|
public void TryCompile_EmptyCode_ReturnsFailure()
|
|
{
|
|
var result = _sut.TryCompile("", "Test");
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("empty", result.Error, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryCompile_WhitespaceOnly_ReturnsFailure()
|
|
{
|
|
var result = _sut.TryCompile(" \n\t ", "Test");
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("empty", result.Error, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
// --- Forbidden-API gate (authoritative) ---
|
|
|
|
[Fact]
|
|
public void TryCompile_ForbiddenApi_ReturnsFailure()
|
|
{
|
|
var result = _sut.TryCompile("using System.IO; File.ReadAllText(\"x\");", "Test");
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("forbidden", result.Error, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryCompile_MultipleForbiddenApis_ReportsAll()
|
|
{
|
|
// #05-T24: the failure message must enumerate EVERY forbidden API, not
|
|
// just the first, so an operator fixing one isn't surprised by the next.
|
|
var result = _sut.TryCompile(
|
|
"System.IO.File.ReadAllText(\"x\"); System.Diagnostics.Process.Start(\"y\");",
|
|
"Test");
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("System.IO", result.Error, StringComparison.Ordinal);
|
|
Assert.Contains("Process", result.Error, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Theory]
|
|
// Namespace alias — the old substring scan never saw "System.IO." spelled out.
|
|
[InlineData("using IO = System.IO; IO.File.ReadAllText(\"x\");")]
|
|
// using static — the call site is a bare ReadAllText(...) with no namespace text.
|
|
[InlineData("using static System.IO.File; ReadAllText(\"x\");")]
|
|
// global::-qualified reference.
|
|
[InlineData("global::System.IO.File.ReadAllText(\"x\");")]
|
|
// Reflection gateway — never spells a forbidden namespace at all.
|
|
[InlineData("typeof(string).Assembly.GetType(\"System.IO.File\");")]
|
|
public void TryCompile_AdversarialForbiddenApiBypass_StillRejected(string code)
|
|
{
|
|
var result = _sut.TryCompile(code, "Test");
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("forbidden", result.Error, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryCompile_ForbiddenTypeInAllowedNamespace_RejectedAsForbidden()
|
|
{
|
|
// System.Diagnostics is an ALLOWED namespace (Stopwatch/Debug ok), so the
|
|
// `using` directive can't be flagged; Process is a forbidden TYPE reached
|
|
// as a bare identifier. The validator's full-framework semantic resolution
|
|
// must catch it authoritatively as a forbidden API (not merely as an
|
|
// undefined-symbol compile error).
|
|
var result = _sut.TryCompile(
|
|
"using System.Diagnostics; var p = Process.Start(\"x\");", "Test");
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("forbidden", result.Error, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryCompile_StopwatchInAllowedDiagnostics_ReturnsSuccess()
|
|
{
|
|
// The companion to the Process case: Stopwatch lives in the same allowed
|
|
// System.Diagnostics namespace and must NOT be flagged.
|
|
var result = _sut.TryCompile(
|
|
"using System.Diagnostics; var sw = Stopwatch.StartNew(); var e = sw.ElapsedMilliseconds;",
|
|
"Test");
|
|
Assert.True(result.IsSuccess, result.IsFailure ? result.Error : null);
|
|
}
|
|
|
|
// --- Real-compile gate (the win over the old structural-only scan) ---
|
|
|
|
[Fact]
|
|
public void TryCompile_UndefinedSymbol_ReturnsFailure()
|
|
{
|
|
// A brace-balanced, forbidden-API-free script that references an undefined
|
|
// symbol. The old substring + brace scan accepted this; the real compile
|
|
// rejects it.
|
|
var result = _sut.TryCompile("var x = NoSuchThing.Foo();", "Test");
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("compile", result.Error, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryCompile_SyntaxError_ReturnsFailure()
|
|
{
|
|
// Genuinely malformed — must fail the real compile.
|
|
var result = _sut.TryCompile("if (true) { var x = 1;", "Test");
|
|
Assert.True(result.IsFailure);
|
|
}
|
|
|
|
// --- Valid real script against the script API surface ---
|
|
|
|
[Fact]
|
|
public void TryCompile_ValidScriptUsingApiSurface_ReturnsSuccess()
|
|
{
|
|
const string code = """
|
|
var t = Attributes["Temperature"];
|
|
Attributes["Setpoint"] = 42;
|
|
var r = await ExternalSystem.Call("erp", "sync");
|
|
await Notify.To("ops").Send("s", "m");
|
|
""";
|
|
|
|
var result = _sut.TryCompile(code, "Test");
|
|
Assert.True(result.IsSuccess, result.IsFailure ? result.Error : null);
|
|
}
|
|
|
|
// --- Trigger-expression gate via ValidationService.CheckExpressionSyntax ---
|
|
|
|
[Fact]
|
|
public void CheckExpressionSyntax_ValidExpression_ReturnsNull()
|
|
{
|
|
// A bare boolean expression binding against the trigger globals surface.
|
|
var error = ValidationService.CheckExpressionSyntax("Attributes[\"Temp\"] != null");
|
|
Assert.Null(error);
|
|
}
|
|
|
|
[Fact]
|
|
public void CheckExpressionSyntax_ForbiddenApi_ReturnsMessage()
|
|
{
|
|
var error = ValidationService.CheckExpressionSyntax("System.IO.File.Exists(\"x\")");
|
|
Assert.NotNull(error);
|
|
Assert.Contains("forbidden", error, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public void CheckExpressionSyntax_SameExpressionTwice_SecondCallIsCacheHit()
|
|
{
|
|
ScriptCompileVerdictCache.Clear();
|
|
ValidationService.CheckExpressionSyntax("Attributes[\"Temp\"] != null");
|
|
var hitsBefore = ScriptCompileVerdictCache.Hits;
|
|
var error = ValidationService.CheckExpressionSyntax("Attributes[\"Temp\"] != null");
|
|
Assert.Null(error);
|
|
Assert.Equal(hitsBefore + 1, ScriptCompileVerdictCache.Hits); // FAILS today: no cache use
|
|
}
|
|
|
|
[Fact]
|
|
public void CheckExpressionSyntax_ScriptSurfaceVerdict_NotReusedForTriggerSurface()
|
|
{
|
|
// "Notify != null" resolves on ScriptCompileSurface (Notify is a script global,
|
|
// ScriptCompileSurface.cs:53) but NOT on TriggerCompileSurface — a shared
|
|
// code-only cache entry would wrongly report the trigger expression clean.
|
|
ScriptCompileVerdictCache.Clear();
|
|
Assert.True(new ScriptCompiler().TryCompile("Notify != null", "S").IsSuccess); // warms the SCRIPT-surface entry
|
|
var error = ValidationService.CheckExpressionSyntax("Notify != null");
|
|
Assert.NotNull(error); // green today (no cache), and MUST STAY green after T3 — the T2 key makes it structural
|
|
}
|
|
|
|
[Fact]
|
|
public void CheckExpressionSyntax_MultipleForbiddenApis_ReportsAll()
|
|
{
|
|
var error = ValidationService.CheckExpressionSyntax(
|
|
"System.IO.File.Exists(\"x\") && System.Diagnostics.Process.GetProcesses().Length > 0");
|
|
Assert.NotNull(error);
|
|
Assert.Contains("System.IO", error); // FAILS today: only violations[0] is surfaced
|
|
Assert.Contains("Process", error);
|
|
}
|
|
|
|
[Fact]
|
|
public void CheckExpressionSyntax_MultipleCompileErrors_ReportsAll()
|
|
{
|
|
var error = ValidationService.CheckExpressionSyntax("NoSuchThingA > 1 && NoSuchThingB < 2");
|
|
Assert.NotNull(error);
|
|
Assert.Contains("NoSuchThingA", error); // FAILS today: only errors[0] is surfaced
|
|
Assert.Contains("NoSuchThingB", error);
|
|
}
|
|
|
|
// --- Compile-verdict cache: unchanged code compiles once per process ---
|
|
|
|
[Fact]
|
|
public void TryCompile_SameCodeTwice_SecondCallIsCacheHit()
|
|
{
|
|
ScriptCompileVerdictCache.Clear();
|
|
var c = new ScriptCompiler();
|
|
c.TryCompile("return 1;", "A");
|
|
var hitsBefore = ScriptCompileVerdictCache.Hits;
|
|
var r = c.TryCompile("return 1;", "B"); // same code, different name
|
|
Assert.True(r.IsSuccess);
|
|
Assert.Equal(hitsBefore + 1, ScriptCompileVerdictCache.Hits);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetOrAdd_SameCodeDifferentSurface_ComputesSeparateVerdicts()
|
|
{
|
|
ScriptCompileVerdictCache.Clear();
|
|
var a = ScriptCompileVerdictCache.GetOrAdd("SurfaceA", "return 1;", () => (true, null));
|
|
var hitsAfterA = ScriptCompileVerdictCache.Hits;
|
|
var b = ScriptCompileVerdictCache.GetOrAdd("SurfaceB", "return 1;", () => (false, "err"));
|
|
|
|
Assert.True(a.Ok);
|
|
Assert.False(b.Ok); // code-only key would return SurfaceA's verdict
|
|
Assert.Equal(hitsAfterA, ScriptCompileVerdictCache.Hits); // no false cross-surface hit
|
|
Assert.Equal(2, ScriptCompileVerdictCache.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryCompile_CachedFailure_FormatsNameForEachCaller()
|
|
{
|
|
// The stored verdict is name-free; the script name is formatted in at read
|
|
// time, so two callers of the same bad code get their own name in the message.
|
|
ScriptCompileVerdictCache.Clear();
|
|
var c = new ScriptCompiler();
|
|
var first = c.TryCompile("var x = NoSuchThing.Foo();", "Alpha");
|
|
var second = c.TryCompile("var x = NoSuchThing.Foo();", "Beta"); // cache hit
|
|
|
|
Assert.True(first.IsFailure);
|
|
Assert.True(second.IsFailure);
|
|
Assert.Contains("'Alpha'", first.Error);
|
|
Assert.Contains("'Beta'", second.Error);
|
|
Assert.Contains("compile", second.Error, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
}
|