feat(templateengine): M3.2 deploy gate delegates to shared ScriptAnalysis (real compile + authoritative forbidden-API)

This commit is contained in:
Joseph Doherty
2026-06-16 19:36:03 -04:00
parent 784fee7b07
commit 14bd25196a
4 changed files with 119 additions and 298 deletions
@@ -2,17 +2,18 @@ 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_ValidCode_ReturnsSuccess()
{
var result = _sut.TryCompile("var x = 1; if (x > 0) { x++; }", "Test");
Assert.True(result.IsSuccess);
}
[Fact]
public void TryCompile_EmptyCode_ReturnsFailure()
{
@@ -22,134 +23,91 @@ public class ScriptCompilerTests
}
[Fact]
public void TryCompile_MismatchedBraces_ReturnsFailure()
public void TryCompile_WhitespaceOnly_ReturnsFailure()
{
var result = _sut.TryCompile("if (true) { x = 1;", "Test");
var result = _sut.TryCompile(" \n\t ", "Test");
Assert.True(result.IsFailure);
Assert.Contains("braces", result.Error, StringComparison.OrdinalIgnoreCase);
Assert.Contains("empty", result.Error, StringComparison.OrdinalIgnoreCase);
}
// --- Forbidden-API gate (authoritative) ---
[Fact]
public void TryCompile_UnclosedBlockComment_ReturnsFailure()
public void TryCompile_ForbiddenApi_ReturnsFailure()
{
var result = _sut.TryCompile("/* this is never closed", "Test");
var result = _sut.TryCompile("using System.IO; File.ReadAllText(\"x\");", "Test");
Assert.True(result.IsFailure);
Assert.Contains("comment", result.Error, StringComparison.OrdinalIgnoreCase);
Assert.Contains("forbidden", result.Error, StringComparison.OrdinalIgnoreCase);
}
[Theory]
[InlineData("System.IO.File.ReadAllText(\"x\");")]
[InlineData("System.Diagnostics.Process.Start(\"cmd\");")]
[InlineData("System.Threading.Thread.Sleep(1000);")]
[InlineData("System.Reflection.Assembly.Load(\"x\");")]
[InlineData("System.Net.Sockets.TcpClient c;")]
[InlineData("System.Net.Http.HttpClient c;")]
public void TryCompile_ForbiddenApi_ReturnsFailure(string code)
// 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_BracesInStrings_Ignored()
{
var result = _sut.TryCompile("var s = \"{ not a brace }\";", "Test");
Assert.True(result.IsSuccess);
}
// --- Real-compile gate (the win over the old structural-only scan) ---
[Fact]
public void TryCompile_BracesInComments_Ignored()
public void TryCompile_UndefinedSymbol_ReturnsFailure()
{
var result = _sut.TryCompile("// { not a brace\nvar x = 1;", "Test");
Assert.True(result.IsSuccess);
}
[Fact]
public void TryCompile_BlockCommentWithBraces_Ignored()
{
var result = _sut.TryCompile("/* { } */ var x = 1;", "Test");
Assert.True(result.IsSuccess);
}
// --- TemplateEngine-007 regression: string-literal awareness ---
[Fact]
public void TryCompile_VerbatimStringWithBrace_NotFlaggedAsMismatched()
{
// @"..." — backslash is literal, "" is the escape. The closing brace
// inside the verbatim string must not affect the brace balance.
var result = _sut.TryCompile("var s = @\"a brace } and a \\ slash\"; if (true) { }", "Test");
Assert.True(result.IsSuccess);
}
[Fact]
public void TryCompile_VerbatimStringWithEscapedQuote_NotFlaggedAsMismatched()
{
// The "" inside a verbatim string is an escaped quote, not a string end.
var result = _sut.TryCompile("var s = @\"he said \"\"hi}\"\"\"; { }", "Test");
Assert.True(result.IsSuccess);
}
[Fact]
public void TryCompile_InterpolatedStringWithBraces_NotFlaggedAsMismatched()
{
// The braces in $"{x}" are interpolation holes; the literal "}}" is an
// escaped brace. Neither should unbalance the real braces.
var result = _sut.TryCompile("var x = 1; var s = $\"val={x} literal}}\"; if (x>0) { x++; }", "Test");
Assert.True(result.IsSuccess);
}
[Fact]
public void TryCompile_RawStringLiteralWithBraces_NotFlaggedAsMismatched()
{
// C# 11 raw string literal — the triple quotes delimit, braces inside are text.
var result = _sut.TryCompile("var s = \"\"\"a } brace { in raw\"\"\"; { }", "Test");
Assert.True(result.IsSuccess);
}
[Fact]
public void TryCompile_CharLiteralWithBrace_NotFlaggedAsMismatched()
{
// A '}' char literal must not decrement the brace depth.
var result = _sut.TryCompile("var c = '}'; if (true) { }", "Test");
Assert.True(result.IsSuccess);
}
[Fact]
public void TryCompile_GenuineMismatchedBraces_StillDetected()
{
// Sanity check that the string-aware scan still catches real mismatches.
var result = _sut.TryCompile("var s = \"ok\"; if (true) { x++;", "Test");
// 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("braces", result.Error, StringComparison.OrdinalIgnoreCase);
}
// --- TemplateEngine-006 regression: forbidden-API scan false positives ---
[Fact]
public void TryCompile_ForbiddenApiTextInsideStringLiteral_NotFlagged()
{
// "System.IO." appears only inside a string literal — it is inert text,
// not a use of the forbidden API, and must not be rejected.
var result = _sut.TryCompile("var msg = \"see System.IO.File docs\"; var x = 1;", "Test");
Assert.True(result.IsSuccess);
Assert.Contains("compile", result.Error, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void TryCompile_ForbiddenApiTextInsideComment_NotFlagged()
public void TryCompile_SyntaxError_ReturnsFailure()
{
// "System.Threading." appears only inside a comment — inert.
var result = _sut.TryCompile("// avoid System.Threading.Thread here\nvar x = 1;", "Test");
Assert.True(result.IsSuccess);
}
[Fact]
public void TryCompile_ForbiddenApiInRealCode_StillFlagged()
{
// Sanity check: a genuine use in code is still rejected.
var result = _sut.TryCompile("var x = System.IO.File.ReadAllText(\"a\");", "Test");
// Genuinely malformed — must fail the real compile.
var result = _sut.TryCompile("if (true) { var x = 1;", "Test");
Assert.True(result.IsFailure);
Assert.Contains("forbidden", result.Error, StringComparison.OrdinalIgnoreCase);
}
// --- 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);
}
}