fix(inbound-api): resolve InboundAPI-014..017 — return-value validation, reflection-gateway hardening, deadline-bound routed calls, RouteHelper test coverage

This commit is contained in:
Joseph Doherty
2026-05-17 03:18:33 -04:00
parent aca65e85bb
commit 73a393076a
12 changed files with 993 additions and 34 deletions

View File

@@ -0,0 +1,104 @@
namespace ScadaLink.InboundAPI.Tests;
/// <summary>
/// InboundAPI-005 / InboundAPI-015: tests for the script-trust-model checker.
///
/// InboundAPI-015 hardens the textual walker against reflection reached through
/// permitted-type members that never spell a forbidden namespace, e.g.
/// <c>typeof(string).Assembly.GetType("System.IO.File")</c>.
/// </summary>
public class ForbiddenApiCheckerTests
{
private static bool IsRejected(string script) =>
ForbiddenApiChecker.FindViolations(script).Count > 0;
// --- Baseline: legitimate scripts must still pass ---
[Theory]
[InlineData("return 1 + 1;")]
[InlineData("var list = new List<int> { 1, 2, 3 }; return list.Sum();")]
[InlineData("return Parameters.Get<int>(\"x\") * 2;")]
[InlineData("await Task.Delay(1); return null;")]
[InlineData("var r = await Route.To(\"inst\").Call(\"s\"); return r;")]
[InlineData("Action a = () => {}; a.Invoke(); return null;")]
public void PermittedScript_NotRejected(string script)
{
Assert.False(IsRejected(script), script);
}
// --- Baseline: forbidden namespaces (textual) must still be rejected ---
[Theory]
[InlineData("System.IO.File.Delete(\"/tmp/x\"); return null;")]
[InlineData("System.Diagnostics.Process.Start(\"/bin/sh\"); return null;")]
[InlineData("using System.Reflection; return null;")]
[InlineData("var s = new System.Net.Sockets.Socket(default, default, default); return null;")]
public void ForbiddenNamespace_Rejected(string script)
{
Assert.True(IsRejected(script), script);
}
// --- InboundAPI-015: reflection reachable without a forbidden namespace token ---
[Fact]
public void Reflection_AssemblyPropertyAccess_Rejected()
{
// typeof(string).Assembly — .Assembly is a reflection gateway off a permitted type.
Assert.True(IsRejected("var a = typeof(string).Assembly; return null;"));
}
[Fact]
public void Reflection_AssemblyGetType_Rejected()
{
// The classic bypass: obtain System.IO.File as a Type via a string literal.
Assert.True(IsRejected(
"var t = typeof(string).Assembly.GetType(\"System.IO.File\"); return null;"));
}
[Fact]
public void Reflection_ObjectGetType_Rejected()
{
// x.GetType() returns a System.Type — a reflection gateway.
Assert.True(IsRejected("var t = \"\".GetType(); return null;"));
}
[Fact]
public void Reflection_TypeGetTypeStatic_Rejected()
{
Assert.True(IsRejected("var t = Type.GetType(\"System.IO.File\"); return null;"));
}
[Fact]
public void Reflection_ActivatorCreateInstance_Rejected()
{
Assert.True(IsRejected(
"var o = Activator.CreateInstance(Type.GetType(\"System.IO.File\")); return null;"));
}
[Fact]
public void Reflection_InvokeMember_Rejected()
{
Assert.True(IsRejected(
"typeof(object).InvokeMember(\"x\", default, null, null, null); return null;"));
}
[Fact]
public void Reflection_GetMethodInvoke_Rejected()
{
Assert.True(IsRejected(
"var m = typeof(object).GetMethod(\"ToString\"); return null;"));
}
[Fact]
public void Reflection_GetTypeInfo_Rejected()
{
Assert.True(IsRejected("var ti = \"\".GetType().GetTypeInfo(); return null;"));
}
[Fact]
public void DynamicKeyword_Rejected()
{
// dynamic widens late-bound member access the static walker cannot see through.
Assert.True(IsRejected("dynamic d = Parameters; return null;"));
}
}

View File

@@ -3,7 +3,6 @@ using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ScadaLink.Commons.Entities.InboundApi;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Communication;
namespace ScadaLink.InboundAPI.Tests;
@@ -20,10 +19,8 @@ public class InboundScriptExecutorTests
{
_executor = new InboundScriptExecutor(NullLogger<InboundScriptExecutor>.Instance, Substitute.For<IServiceProvider>());
var locator = Substitute.For<IInstanceLocator>();
var commService = Substitute.For<CommunicationService>(
Microsoft.Extensions.Options.Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
_route = new RouteHelper(locator, commService);
var router = Substitute.For<IInstanceRouter>();
_route = new RouteHelper(locator, router);
}
[Fact]
@@ -364,6 +361,72 @@ public class InboundScriptExecutorTests
Assert.True(_executor.CompileAndRegister(good));
}
// --- InboundAPI-014: the script return value is validated against ReturnDefinition ---
[Fact]
public async Task ReturnValue_MatchingReturnDefinition_Succeeds()
{
var method = new ApiMethod("shaped", "return x;")
{
Id = 1,
TimeoutSeconds = 10,
ReturnDefinition = """[{"name":"siteName","type":"String"},{"name":"total","type":"Integer"}]""",
};
_executor.RegisterHandler("shaped", async ctx =>
{
await Task.CompletedTask;
return new { siteName = "Site Alpha", total = 14250 };
});
var result = await _executor.ExecuteAsync(
method, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10));
Assert.True(result.Success, result.ErrorMessage);
}
[Fact]
public async Task ReturnValue_NotMatchingReturnDefinition_ReturnsFailureNotMalformed200()
{
// The script returns a structure inconsistent with the declared return
// definition (missing 'total'). It must surface as a failure, not a 200.
var method = new ApiMethod("misshaped", "return x;")
{
Id = 1,
TimeoutSeconds = 10,
ReturnDefinition = """[{"name":"siteName","type":"String"},{"name":"total","type":"Integer"}]""",
};
_executor.RegisterHandler("misshaped", async ctx =>
{
await Task.CompletedTask;
return new { siteName = "Site Alpha" }; // 'total' missing
});
var result = await _executor.ExecuteAsync(
method, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10));
Assert.False(result.Success);
Assert.Null(result.ResultJson);
}
[Fact]
public async Task ReturnValue_NoReturnDefinition_IsUnconstrained()
{
// A method with no ReturnDefinition keeps the prior behaviour — the return
// value is serialized as-is.
var method = new ApiMethod("free", "return x;") { Id = 1, TimeoutSeconds = 10 };
_executor.RegisterHandler("free", async ctx =>
{
await Task.CompletedTask;
return new { whatever = 1 };
});
var result = await _executor.ExecuteAsync(
method, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10));
Assert.True(result.Success, result.ErrorMessage);
Assert.Contains("whatever", result.ResultJson!);
}
private sealed class CompileLogCounter
{
public int CompilationFailures;

View File

@@ -0,0 +1,118 @@
namespace ScadaLink.InboundAPI.Tests;
/// <summary>
/// InboundAPI-014: tests for return-value validation against a method's
/// <c>ReturnDefinition</c>. Previously the script's return value was serialized
/// verbatim with no checking against the declared return structure.
/// </summary>
public class ReturnValueValidatorTests
{
// --- No definition → no validation (backward compatible) ---
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void NoReturnDefinition_AnythingIsValid(string? returnDefinition)
{
var result = ReturnValueValidator.Validate("{\"anything\":1}", returnDefinition);
Assert.True(result.IsValid);
}
[Fact]
public void NoReturnDefinition_NullResult_IsValid()
{
var result = ReturnValueValidator.Validate(null, null);
Assert.True(result.IsValid);
}
// --- Happy path: result matches the declared field shape ---
[Fact]
public void ResultMatchingDefinition_IsValid()
{
const string def = """[{"name":"siteName","type":"String"},{"name":"totalUnits","type":"Integer"}]""";
const string json = """{"siteName":"Site Alpha","totalUnits":14250}""";
var result = ReturnValueValidator.Validate(json, def);
Assert.True(result.IsValid);
}
[Fact]
public void ResultWithListField_ShapeChecked_IsValid()
{
const string def = """[{"name":"lines","type":"List"}]""";
const string json = """{"lines":[{"lineName":"Line-1","units":8200}]}""";
var result = ReturnValueValidator.Validate(json, def);
Assert.True(result.IsValid);
}
// --- Mismatches must be reported ---
[Fact]
public void ResultMissingDeclaredField_IsInvalid()
{
const string def = """[{"name":"siteName","type":"String"},{"name":"totalUnits","type":"Integer"}]""";
const string json = """{"siteName":"Site Alpha"}""";
var result = ReturnValueValidator.Validate(json, def);
Assert.False(result.IsValid);
Assert.Contains("totalUnits", result.ErrorMessage);
}
[Fact]
public void ResultFieldWrongType_IsInvalid()
{
const string def = """[{"name":"totalUnits","type":"Integer"}]""";
const string json = """{"totalUnits":"not-a-number"}""";
var result = ReturnValueValidator.Validate(json, def);
Assert.False(result.IsValid);
Assert.Contains("totalUnits", result.ErrorMessage);
}
[Fact]
public void NullResultWhenStructureRequired_IsInvalid()
{
const string def = """[{"name":"siteName","type":"String"}]""";
var result = ReturnValueValidator.Validate(null, def);
Assert.False(result.IsValid);
}
[Fact]
public void NonObjectResultWhenStructureRequired_IsInvalid()
{
const string def = """[{"name":"siteName","type":"String"}]""";
var result = ReturnValueValidator.Validate("42", def);
Assert.False(result.IsValid);
}
[Fact]
public void ListFieldGivenNonArray_IsInvalid()
{
const string def = """[{"name":"lines","type":"List"}]""";
const string json = """{"lines":"not-a-list"}""";
var result = ReturnValueValidator.Validate(json, def);
Assert.False(result.IsValid);
Assert.Contains("lines", result.ErrorMessage);
}
[Fact]
public void MalformedReturnDefinition_IsInvalid()
{
var result = ReturnValueValidator.Validate("{\"x\":1}", "%%% not json %%%");
Assert.False(result.IsValid);
}
}

View File

@@ -0,0 +1,268 @@
using NSubstitute;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Messages.InboundApi;
namespace ScadaLink.InboundAPI.Tests;
/// <summary>
/// WP-4: Tests for <see cref="RouteHelper"/>/<see cref="RouteTarget"/> — the
/// cross-site Route.To() routing surface inbound API scripts use.
///
/// InboundAPI-017: this surface previously had zero coverage.
/// InboundAPI-016: routed calls must inherit the executing method's deadline token.
/// </summary>
public class RouteHelperTests
{
private readonly IInstanceLocator _locator = Substitute.For<IInstanceLocator>();
private readonly IInstanceRouter _router = Substitute.For<IInstanceRouter>();
private RouteHelper CreateHelper() => new(_locator, _router);
private void SiteResolves(string instanceCode, string siteId) =>
_locator.GetSiteIdForInstanceAsync(instanceCode, Arg.Any<CancellationToken>())
.Returns(siteId);
// --- Call ---
[Fact]
public async Task Call_HappyPath_ResolvesSiteAndReturnsValue()
{
SiteResolves("inst-1", "SiteA");
_router.RouteToCallAsync("SiteA", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToCallResponse(
((RouteToCallRequest)ci[1]).CorrelationId, true, 99, null, DateTimeOffset.UtcNow));
var result = await CreateHelper().To("inst-1").Call("doWork", new { x = 1 });
Assert.Equal(99, result);
}
[Fact]
public async Task Call_GeneratesCorrelationId()
{
SiteResolves("inst-1", "SiteA");
RouteToCallRequest? captured = null;
_router.RouteToCallAsync("SiteA", Arg.Do<RouteToCallRequest>(r => captured = r), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToCallResponse(
((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow));
await CreateHelper().To("inst-1").Call("doWork");
Assert.NotNull(captured);
Assert.True(Guid.TryParse(captured!.CorrelationId, out _));
}
[Fact]
public async Task Call_RemoteFailure_ThrowsInvalidOperationException()
{
SiteResolves("inst-1", "SiteA");
_router.RouteToCallAsync("SiteA", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToCallResponse(
((RouteToCallRequest)ci[1]).CorrelationId, false, null, "site exploded", DateTimeOffset.UtcNow));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => CreateHelper().To("inst-1").Call("doWork"));
Assert.Equal("site exploded", ex.Message);
}
[Fact]
public async Task Call_UnresolvedInstance_ThrowsInvalidOperationException()
{
// Locator returns null → instance not found / no assigned site.
_locator.GetSiteIdForInstanceAsync("ghost", Arg.Any<CancellationToken>())
.Returns((string?)null);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => CreateHelper().To("ghost").Call("doWork"));
Assert.Contains("ghost", ex.Message);
}
// --- GetAttribute(s) ---
[Fact]
public async Task GetAttributes_HappyPath_ReturnsValues()
{
SiteResolves("inst-1", "SiteA");
_router.RouteToGetAttributesAsync("SiteA", Arg.Any<RouteToGetAttributesRequest>(), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToGetAttributesResponse(
((RouteToGetAttributesRequest)ci[1]).CorrelationId,
new Dictionary<string, object?> { ["a"] = 1, ["b"] = 2 },
true, null, DateTimeOffset.UtcNow));
var result = await CreateHelper().To("inst-1").GetAttributes(new[] { "a", "b" });
Assert.Equal(1, result["a"]);
Assert.Equal(2, result["b"]);
}
[Fact]
public async Task GetAttribute_DelegatesToBatch_AndReturnsSingleValue()
{
SiteResolves("inst-1", "SiteA");
_router.RouteToGetAttributesAsync("SiteA", Arg.Any<RouteToGetAttributesRequest>(), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToGetAttributesResponse(
((RouteToGetAttributesRequest)ci[1]).CorrelationId,
new Dictionary<string, object?> { ["temp"] = 21.5 },
true, null, DateTimeOffset.UtcNow));
var value = await CreateHelper().To("inst-1").GetAttribute("temp");
Assert.Equal(21.5, value);
}
[Fact]
public async Task GetAttribute_AbsentKey_ReturnsNull()
{
SiteResolves("inst-1", "SiteA");
_router.RouteToGetAttributesAsync("SiteA", Arg.Any<RouteToGetAttributesRequest>(), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToGetAttributesResponse(
((RouteToGetAttributesRequest)ci[1]).CorrelationId,
new Dictionary<string, object?>(), // batch returns nothing for the key
true, null, DateTimeOffset.UtcNow));
var value = await CreateHelper().To("inst-1").GetAttribute("missing");
Assert.Null(value);
}
[Fact]
public async Task GetAttributes_RemoteFailure_ThrowsInvalidOperationException()
{
SiteResolves("inst-1", "SiteA");
_router.RouteToGetAttributesAsync("SiteA", Arg.Any<RouteToGetAttributesRequest>(), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToGetAttributesResponse(
((RouteToGetAttributesRequest)ci[1]).CorrelationId,
new Dictionary<string, object?>(), false, "read failed", DateTimeOffset.UtcNow));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => CreateHelper().To("inst-1").GetAttributes(new[] { "a" }));
Assert.Equal("read failed", ex.Message);
}
// --- SetAttribute(s) ---
[Fact]
public async Task SetAttribute_DelegatesToBatch_WithSingleEntry()
{
SiteResolves("inst-1", "SiteA");
RouteToSetAttributesRequest? captured = null;
_router.RouteToSetAttributesAsync("SiteA", Arg.Do<RouteToSetAttributesRequest>(r => captured = r), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToSetAttributesResponse(
((RouteToSetAttributesRequest)ci[1]).CorrelationId, true, null, DateTimeOffset.UtcNow));
await CreateHelper().To("inst-1").SetAttribute("setpoint", "42");
Assert.NotNull(captured);
Assert.Equal("42", captured!.AttributeValues["setpoint"]);
Assert.Single(captured.AttributeValues);
}
[Fact]
public async Task SetAttributes_RemoteFailure_ThrowsInvalidOperationException()
{
SiteResolves("inst-1", "SiteA");
_router.RouteToSetAttributesAsync("SiteA", Arg.Any<RouteToSetAttributesRequest>(), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToSetAttributesResponse(
((RouteToSetAttributesRequest)ci[1]).CorrelationId, false, "write rejected", DateTimeOffset.UtcNow));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => CreateHelper().To("inst-1").SetAttributes(
new Dictionary<string, string> { ["x"] = "1" }));
Assert.Equal("write rejected", ex.Message);
}
// --- InboundAPI-016: routed calls inherit the method deadline token ---
[Fact]
public async Task Call_WithNoExplicitToken_InheritsMethodDeadlineToken()
{
// A natural script — Route.To("x").Call("s", p) — passes no token. The routed
// call must still be bounded by the executing method's timeout: the helper
// bound to a deadline token must forward THAT token to the router.
SiteResolves("inst-1", "SiteA");
using var deadline = new CancellationTokenSource();
CancellationToken seen = default;
_router.RouteToCallAsync("SiteA", Arg.Any<RouteToCallRequest>(), Arg.Do<CancellationToken>(t => seen = t))
.Returns(ci => new RouteToCallResponse(
((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow));
var bound = CreateHelper().WithDeadline(deadline.Token);
await bound.To("inst-1").Call("doWork");
Assert.Equal(deadline.Token, seen);
}
[Fact]
public async Task Call_WhenMethodDeadlineCancelled_RoutedCallObservesCancellation()
{
// When the method timeout fires, an in-flight routed call must see the
// cancellation rather than running orphaned past the deadline.
SiteResolves("inst-1", "SiteA");
using var deadline = new CancellationTokenSource();
_router.RouteToCallAsync("SiteA", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
.Returns(async ci =>
{
var token = (CancellationToken)ci[2];
await Task.Delay(TimeSpan.FromSeconds(30), token);
return new RouteToCallResponse(
((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow);
});
var bound = CreateHelper().WithDeadline(deadline.Token);
deadline.CancelAfter(TimeSpan.FromMilliseconds(100));
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => bound.To("inst-1").Call("doWork"));
}
[Fact]
public async Task Call_ExplicitToken_OverridesDeadlineToken()
{
// A script that DOES pass a (tighter) token must have that token honoured.
SiteResolves("inst-1", "SiteA");
using var deadline = new CancellationTokenSource();
using var explicitCts = new CancellationTokenSource();
CancellationToken seen = default;
_router.RouteToCallAsync("SiteA", Arg.Any<RouteToCallRequest>(), Arg.Do<CancellationToken>(t => seen = t))
.Returns(ci => new RouteToCallResponse(
((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow));
var bound = CreateHelper().WithDeadline(deadline.Token);
await bound.To("inst-1").Call("doWork", null, explicitCts.Token);
Assert.Equal(explicitCts.Token, seen);
}
[Fact]
public async Task GetAttributes_WithNoExplicitToken_InheritsMethodDeadlineToken()
{
SiteResolves("inst-1", "SiteA");
using var deadline = new CancellationTokenSource();
CancellationToken seen = default;
_router.RouteToGetAttributesAsync("SiteA", Arg.Any<RouteToGetAttributesRequest>(), Arg.Do<CancellationToken>(t => seen = t))
.Returns(ci => new RouteToGetAttributesResponse(
((RouteToGetAttributesRequest)ci[1]).CorrelationId,
new Dictionary<string, object?>(), true, null, DateTimeOffset.UtcNow));
var bound = CreateHelper().WithDeadline(deadline.Token);
await bound.To("inst-1").GetAttributes(new[] { "a" });
Assert.Equal(deadline.Token, seen);
}
[Fact]
public async Task SetAttributes_WithNoExplicitToken_InheritsMethodDeadlineToken()
{
SiteResolves("inst-1", "SiteA");
using var deadline = new CancellationTokenSource();
CancellationToken seen = default;
_router.RouteToSetAttributesAsync("SiteA", Arg.Any<RouteToSetAttributesRequest>(), Arg.Do<CancellationToken>(t => seen = t))
.Returns(ci => new RouteToSetAttributesResponse(
((RouteToSetAttributesRequest)ci[1]).CorrelationId, true, null, DateTimeOffset.UtcNow));
var bound = CreateHelper().WithDeadline(deadline.Token);
await bound.To("inst-1").SetAttributes(new Dictionary<string, string> { ["x"] = "1" });
Assert.Equal(deadline.Token, seen);
}
}