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:
104
tests/ScadaLink.InboundAPI.Tests/ForbiddenApiCheckerTests.cs
Normal file
104
tests/ScadaLink.InboundAPI.Tests/ForbiddenApiCheckerTests.cs
Normal 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;"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
118
tests/ScadaLink.InboundAPI.Tests/ReturnValueValidatorTests.cs
Normal file
118
tests/ScadaLink.InboundAPI.Tests/ReturnValueValidatorTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
268
tests/ScadaLink.InboundAPI.Tests/RouteHelperTests.cs
Normal file
268
tests/ScadaLink.InboundAPI.Tests/RouteHelperTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user