merge(r2): r2-plan06
# Conflicts: # CLAUDE.md
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
@@ -1341,4 +1342,93 @@ public class ExternalSystemClientTests
|
||||
() => client.CallAsync("TestAPI", "getRecipe"));
|
||||
Assert.Contains("id", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeliverBuffered_PathTemplateNowUnresolvable_ReturnsFalseSoMessageParks()
|
||||
{
|
||||
// Arch-review R2 N2: a method whose path gained a {param} placeholder AFTER
|
||||
// calls were buffered throws ArgumentException deterministically for every
|
||||
// already-buffered message — it must park immediately (permanent), not burn
|
||||
// MaxRetries "transient" attempts (the S&F sweep's catch-all retries any throw).
|
||||
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
||||
var method = new ExternalSystemMethod("getRecipe", "GET", "/recipes/{id}") { Id = 1, ExternalSystemDefinitionId = 1 };
|
||||
StubResolution(system, method);
|
||||
_httpClientFactory.CreateClient(Arg.Any<string>())
|
||||
.Returns(new HttpClient(new MockHttpMessageHandler(HttpStatusCode.OK, "{}")));
|
||||
var client = new ExternalSystemClient(
|
||||
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
|
||||
|
||||
// BufferedCall carries Parameters:null — the {id} placeholder has no value.
|
||||
var delivered = await client.DeliverBufferedAsync(BufferedCall("TestAPI", "getRecipe"));
|
||||
|
||||
Assert.False(delivered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeliverBuffered_UnsupportedVerbOnStoredMethod_ReturnsFalseSoMessageParks()
|
||||
{
|
||||
// ValidateHttpMethod throws the same deterministic ArgumentException shape.
|
||||
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
||||
var method = new ExternalSystemMethod("oddVerb", "FOO", "/p") { Id = 1, ExternalSystemDefinitionId = 1 };
|
||||
StubResolution(system, method);
|
||||
var client = new ExternalSystemClient(
|
||||
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
|
||||
|
||||
var delivered = await client.DeliverBufferedAsync(BufferedCall("TestAPI", "oddVerb"));
|
||||
|
||||
Assert.False(delivered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Call_Latin1Charset_DecodesUsingDeclaredCharset()
|
||||
{
|
||||
// Arch-review R2 N4: the bounded read must honor the declared charset —
|
||||
// UTF-8-decoding an iso-8859-1 body turns every non-ASCII byte into U+FFFD.
|
||||
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
||||
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
||||
StubResolution(system, method);
|
||||
var handler = new ByteBodyHandler(
|
||||
HttpStatusCode.OK,
|
||||
Encoding.Latin1.GetBytes("température 25°C"),
|
||||
"text/plain; charset=iso-8859-1");
|
||||
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
|
||||
var client = new ExternalSystemClient(
|
||||
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
|
||||
|
||||
var result = await client.CallAsync("TestAPI", "getData");
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Contains("température 25°C", result.ResponseJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Call_UnknownCharset_FallsBackToUtf8()
|
||||
{
|
||||
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
||||
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
||||
StubResolution(system, method);
|
||||
var handler = new ByteBodyHandler(
|
||||
HttpStatusCode.OK, Encoding.UTF8.GetBytes("ok"), "text/plain; charset=not-a-charset");
|
||||
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
|
||||
var client = new ExternalSystemClient(
|
||||
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
|
||||
|
||||
var result = await client.CallAsync("TestAPI", "getData");
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Contains("ok", result.ResponseJson);
|
||||
}
|
||||
|
||||
/// <summary>Test helper: returns a fixed byte body with an explicit Content-Type.</summary>
|
||||
private sealed class ByteBodyHandler(
|
||||
HttpStatusCode statusCode, byte[] body, string contentType) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var response = new HttpResponseMessage(statusCode) { Content = new ByteArrayContent(body) };
|
||||
response.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType);
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,4 +226,90 @@ public sealed class EndpointContentTypeTests : IDisposable
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChunkedNonJsonBody_Returns415()
|
||||
{
|
||||
// Arch-review R2 N5: a chunked body has NULL ContentLength — it must not
|
||||
// slip past the 415 guard into a misleading VALIDATION_FAILED.
|
||||
const string methodName = "echoChunked";
|
||||
var method = new ApiMethod(methodName, "return Parameters[\"value\"];")
|
||||
{
|
||||
Id = 1,
|
||||
TimeoutSeconds = 10,
|
||||
ParameterDefinitions = """[{"name":"value","type":"Integer","required":true}]""",
|
||||
};
|
||||
var repo = Substitute.For<IInboundApiRepository>();
|
||||
repo.GetMethodByNameAsync(methodName, Arg.Any<CancellationToken>()).Returns(method);
|
||||
|
||||
using var host = await BuildHostAsync(repo);
|
||||
var token = await SeedKeyAsync(host, methodName);
|
||||
var client = host.GetTestClient();
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName)
|
||||
{
|
||||
Content = new ChunkedByteContent(Encoding.UTF8.GetBytes("hello")),
|
||||
};
|
||||
request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("text/plain");
|
||||
request.Headers.TransferEncodingChunked = true;
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode);
|
||||
Assert.Contains("UNSUPPORTED_MEDIA_TYPE", body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChunkedJsonBody_NoContentType_StillParsesLeniently()
|
||||
{
|
||||
// The parse condition must also learn about chunked bodies: a chunked JSON
|
||||
// body with no Content-Type previously skipped parsing entirely (body=null →
|
||||
// misleading "missing required parameter").
|
||||
const string methodName = "echoChunkedJson";
|
||||
var method = new ApiMethod(methodName, "return Parameters[\"value\"];")
|
||||
{
|
||||
Id = 1,
|
||||
TimeoutSeconds = 10,
|
||||
ParameterDefinitions = """[{"name":"value","type":"Integer","required":true}]""",
|
||||
};
|
||||
var repo = Substitute.For<IInboundApiRepository>();
|
||||
repo.GetMethodByNameAsync(methodName, Arg.Any<CancellationToken>()).Returns(method);
|
||||
|
||||
using var host = await BuildHostAsync(repo);
|
||||
var token = await SeedKeyAsync(host, methodName);
|
||||
var client = host.GetTestClient();
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName)
|
||||
{
|
||||
Content = new ChunkedByteContent(Encoding.UTF8.GetBytes("{\"value\":42}")),
|
||||
};
|
||||
request.Content.Headers.ContentType = null;
|
||||
request.Headers.TransferEncodingChunked = true;
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains("42", body);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content whose length is never computable, forcing HttpClient to transmit it
|
||||
/// chunked (no Content-Length header ever reaches the server).
|
||||
/// </summary>
|
||||
private sealed class ChunkedByteContent(byte[] bytes) : HttpContent
|
||||
{
|
||||
protected override Task SerializeToStreamAsync(
|
||||
Stream stream, System.Net.TransportContext? context) =>
|
||||
stream.WriteAsync(bytes, 0, bytes.Length);
|
||||
|
||||
protected override bool TryComputeLength(out long length)
|
||||
{
|
||||
length = -1;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,19 +98,17 @@ public class InboundScriptExecutorTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RoutedSiteUnreachable_Returns500WithSiteUnreachableCode()
|
||||
public async Task RoutedAskTimeout_Returns500WithSiteUnreachableCode()
|
||||
{
|
||||
// C3: a routed call whose site is unreachable must surface the machine-readable
|
||||
// SITE_UNREACHABLE code (spec Component-InboundAPI failure body) rather than
|
||||
// collapsing into a generic SCRIPT_ERROR.
|
||||
// N3: unreachability is the Ask EXPIRING (site never answered) — typed as
|
||||
// AskTimeoutException by the communication layer — not a substring in a
|
||||
// failure message. Pre-fix this fell into the generic catch as SCRIPT_ERROR.
|
||||
var locator = Substitute.For<IInstanceLocator>();
|
||||
locator.GetSiteIdForInstanceAsync("X", Arg.Any<CancellationToken>()).Returns("SiteA");
|
||||
var router = Substitute.For<IInstanceRouter>();
|
||||
router.RouteToCallAsync("SiteA", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToCallResponse(
|
||||
((RouteToCallRequest)ci[1]).CorrelationId,
|
||||
Success: false, ReturnValue: null, ErrorMessage: "Site unreachable",
|
||||
DateTimeOffset.UtcNow));
|
||||
.Returns(Task.FromException<RouteToCallResponse>(
|
||||
new Akka.Actor.AskTimeoutException("Timeout after 00:00:30")));
|
||||
var route = new RouteHelper(locator, router);
|
||||
|
||||
var method = new ApiMethod("routed", "return await Route.To(\"X\").Call(\"s\");")
|
||||
@@ -123,6 +121,33 @@ public class InboundScriptExecutorTests
|
||||
Assert.Equal("SITE_UNREACHABLE", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SiteRespondedFailure_MessageMentioningUnreachable_IsScriptErrorNotSiteUnreachable()
|
||||
{
|
||||
// N3 anti-sniff: a site that RESPONDED with Success=false is reachable by
|
||||
// definition — even when its error text happens to contain "unreachable".
|
||||
// Pre-fix the substring sniff misclassified this as SITE_UNREACHABLE.
|
||||
var locator = Substitute.For<IInstanceLocator>();
|
||||
locator.GetSiteIdForInstanceAsync("X", Arg.Any<CancellationToken>()).Returns("SiteA");
|
||||
var router = Substitute.For<IInstanceRouter>();
|
||||
router.RouteToCallAsync("SiteA", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToCallResponse(
|
||||
((RouteToCallRequest)ci[1]).CorrelationId,
|
||||
Success: false, ReturnValue: null,
|
||||
ErrorMessage: "PLC device unreachable behind the site gateway",
|
||||
DateTimeOffset.UtcNow));
|
||||
var route = new RouteHelper(locator, router);
|
||||
|
||||
var method = new ApiMethod("routed2", "return await Route.To(\"X\").Call(\"s\");")
|
||||
{ Id = 8, TimeoutSeconds = 10 };
|
||||
|
||||
var result = await _executor.ExecuteAsync(
|
||||
method, new Dictionary<string, object?>(), route, TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("SCRIPT_ERROR", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandlerTimesOut_ReturnsTimeoutError()
|
||||
{
|
||||
|
||||
@@ -595,4 +595,30 @@ public class RouteHelperTests
|
||||
Assert.NotNull(captured);
|
||||
Assert.Equal(inboundExecutionId, captured!.ParentExecutionId);
|
||||
}
|
||||
|
||||
// --- N3: typed unreachability at the Ask boundary ---
|
||||
|
||||
[Fact]
|
||||
public async Task Call_AskTimeout_ThrowsSiteUnreachableException()
|
||||
{
|
||||
SiteResolves("inst", "SiteA");
|
||||
_router.RouteToCallAsync("SiteA", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromException<RouteToCallResponse>(
|
||||
new Akka.Actor.AskTimeoutException("Timeout after 00:00:30")));
|
||||
|
||||
await Assert.ThrowsAsync<SiteUnreachableException>(
|
||||
() => CreateHelper().To("inst").Call("s"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAttributes_AskTimeout_ThrowsSiteUnreachableException()
|
||||
{
|
||||
SiteResolves("inst", "SiteA");
|
||||
_router.RouteToGetAttributesAsync("SiteA", Arg.Any<RouteToGetAttributesRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromException<RouteToGetAttributesResponse>(
|
||||
new Akka.Actor.AskTimeoutException("Timeout after 00:00:30")));
|
||||
|
||||
await Assert.ThrowsAsync<SiteUnreachableException>(
|
||||
() => CreateHelper().To("inst").GetAttributes(new[] { "a" }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Scripting;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Arch-review R2 N1: the Inbound API must be a REAL subscriber of
|
||||
/// IScriptArtifactChangeBus — a bundle-import publish must invalidate a cached
|
||||
/// handler immediately, without waiting for the per-request revision-check
|
||||
/// self-heal (which remains the correctness fallback per the invalidation
|
||||
/// contract's normative semantics).
|
||||
/// </summary>
|
||||
public class ScriptArtifactChangeSubscriberTests
|
||||
{
|
||||
/// <summary>Minimal in-test bus: records subscriptions, delivers synchronously.</summary>
|
||||
private sealed class RecordingBus : IScriptArtifactChangeBus
|
||||
{
|
||||
private readonly List<Action<ScriptArtifactsChanged>> _handlers = new();
|
||||
public int SubscriberCount => _handlers.Count;
|
||||
|
||||
public void Publish(ScriptArtifactsChanged notification)
|
||||
{
|
||||
foreach (var handler in _handlers.ToArray()) handler(notification);
|
||||
}
|
||||
|
||||
public IDisposable Subscribe(Action<ScriptArtifactsChanged> handler)
|
||||
{
|
||||
_handlers.Add(handler);
|
||||
return new Token(() => _handlers.Remove(handler));
|
||||
}
|
||||
|
||||
private sealed class Token(Action dispose) : IDisposable
|
||||
{
|
||||
public void Dispose() => dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private readonly InboundScriptExecutor _executor = new(
|
||||
NullLogger<InboundScriptExecutor>.Instance, Substitute.For<IServiceProvider>());
|
||||
private readonly RecordingBus _bus = new();
|
||||
private readonly RouteHelper _route = new(
|
||||
Substitute.For<IInstanceLocator>(), Substitute.For<IInstanceRouter>());
|
||||
|
||||
private ScriptArtifactChangeSubscriber CreateSubscriber(IScriptArtifactChangeBus? bus) =>
|
||||
new(_executor, NullLogger<ScriptArtifactChangeSubscriber>.Instance, bus);
|
||||
|
||||
private Task<InboundScriptResult> Run(ApiMethod m) => _executor.ExecuteAsync(
|
||||
m, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10));
|
||||
|
||||
private static ScriptArtifactsChanged ApiMethodChanged(params string[] names) =>
|
||||
new(ScriptArtifactKinds.ApiMethod, names, "BundleImport", DateTimeOffset.UtcNow);
|
||||
|
||||
[Fact]
|
||||
public async Task ApiMethodPublish_InvalidatesCachedHandler_WithoutRevisionCheckSelfHeal()
|
||||
{
|
||||
var subscriber = CreateSubscriber(_bus);
|
||||
await subscriber.StartAsync(CancellationToken.None);
|
||||
|
||||
// Pin a SCRIPT-LESS handler: the RegisterHandler seam is exempt from the
|
||||
// per-request revision check, so the ONLY mechanism that can stop it
|
||||
// serving is an explicit InvalidateMethod — exactly what the bus must trigger.
|
||||
_executor.RegisterHandler("m", async _ => { await Task.CompletedTask; return 99; });
|
||||
var row = new ApiMethod("m", "return 1;") { Id = 1, TimeoutSeconds = 10 };
|
||||
|
||||
var before = await Run(row);
|
||||
Assert.Contains("99", before.ResultJson); // pinned handler serves; no self-heal fires
|
||||
|
||||
_bus.Publish(ApiMethodChanged("m"));
|
||||
|
||||
var after = await Run(row);
|
||||
Assert.True(after.Success);
|
||||
Assert.Contains("1", after.ResultJson); // recompiled from the fresh row — invalidation, not self-heal
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApiMethodPublish_PurgesKnownBadRecord()
|
||||
{
|
||||
var subscriber = CreateSubscriber(_bus);
|
||||
await subscriber.StartAsync(CancellationToken.None);
|
||||
|
||||
var broken = new ApiMethod("bad", "%%% not C# %%%") { Id = 2, TimeoutSeconds = 10 };
|
||||
Assert.False(_executor.CompileAndRegister(broken));
|
||||
Assert.Equal(1, _executor.KnownBadMethodCount);
|
||||
|
||||
_bus.Publish(ApiMethodChanged("bad"));
|
||||
|
||||
Assert.Equal(0, _executor.KnownBadMethodCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonApiMethodKinds_AreIgnored()
|
||||
{
|
||||
var subscriber = CreateSubscriber(_bus);
|
||||
await subscriber.StartAsync(CancellationToken.None);
|
||||
|
||||
_executor.RegisterHandler("m", async _ => { await Task.CompletedTask; return 99; });
|
||||
var row = new ApiMethod("m", "return 1;") { Id = 3, TimeoutSeconds = 10 };
|
||||
|
||||
_bus.Publish(new ScriptArtifactsChanged(
|
||||
ScriptArtifactKinds.SharedScript, new[] { "m" }, "BundleImport", DateTimeOffset.UtcNow));
|
||||
_bus.Publish(new ScriptArtifactsChanged(
|
||||
ScriptArtifactKinds.Template, new[] { "m" }, "BundleImport", DateTimeOffset.UtcNow));
|
||||
|
||||
var result = await Run(row);
|
||||
Assert.Contains("99", result.ResultJson); // pinned handler untouched
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopAsync_Unsubscribes()
|
||||
{
|
||||
var subscriber = CreateSubscriber(_bus);
|
||||
await subscriber.StartAsync(CancellationToken.None);
|
||||
Assert.Equal(1, _bus.SubscriberCount);
|
||||
|
||||
await subscriber.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, _bus.SubscriberCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NullBus_NoOps()
|
||||
{
|
||||
// Site roles / plain test hosts have no bus registered — the hosted
|
||||
// service must start and stop cleanly (the revision check alone applies).
|
||||
var subscriber = CreateSubscriber(bus: null);
|
||||
await subscriber.StartAsync(CancellationToken.None);
|
||||
await subscriber.StopAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user