feat(inbound-api): machine-readable error codes per spec (incl. SITE_UNREACHABLE) + 415 for non-JSON bodies
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
@@ -92,6 +92,59 @@ public sealed class EndpointContentTypeTests : IDisposable
|
||||
Assert.Contains("42", body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonJsonContentType_WithBody_Returns415()
|
||||
{
|
||||
// Task 5 (S9): a body with a non-JSON Content-Type must be rejected with 415 +
|
||||
// UNSUPPORTED_MEDIA_TYPE rather than a misleading 400 "invalid JSON". A body
|
||||
// with NO Content-Type header is still parsed leniently as JSON (preserving
|
||||
// existing callers).
|
||||
const string methodName = "echo415";
|
||||
|
||||
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();
|
||||
|
||||
// (a) text/plain body → 415 UNSUPPORTED_MEDIA_TYPE
|
||||
var nonJson = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName)
|
||||
{
|
||||
Content = new ByteArrayContent(Encoding.UTF8.GetBytes("hello")),
|
||||
};
|
||||
nonJson.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("text/plain");
|
||||
nonJson.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var nonJsonResponse = await client.SendAsync(nonJson);
|
||||
var nonJsonBody = await nonJsonResponse.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.UnsupportedMediaType, nonJsonResponse.StatusCode);
|
||||
Assert.Contains("UNSUPPORTED_MEDIA_TYPE", nonJsonBody);
|
||||
|
||||
// (b) body with NO Content-Type header → still parses as JSON (lenient), 200.
|
||||
var noContentType = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName)
|
||||
{
|
||||
Content = new ByteArrayContent(Encoding.UTF8.GetBytes("{\"value\":42}")),
|
||||
};
|
||||
noContentType.Content.Headers.ContentType = null;
|
||||
noContentType.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var lenientResponse = await client.SendAsync(noContentType);
|
||||
var lenientBody = await lenientResponse.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, lenientResponse.StatusCode);
|
||||
Assert.Contains("42", lenientBody);
|
||||
}
|
||||
|
||||
/// <summary>Seeds a key scoped for <paramref name="methodName"/> and returns its Bearer token.</summary>
|
||||
private static async Task<string> SeedKeyAsync(IHost host, string methodName)
|
||||
{
|
||||
|
||||
@@ -26,6 +26,7 @@ using System.Diagnostics.Metrics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
@@ -67,8 +68,11 @@ public sealed class EndpointExtensionsTests : IDisposable
|
||||
// two bodies are equal-to-each-other) catches a single-branch divergence where
|
||||
// BOTH branches change in lockstep but away from the agreed enumeration-safe
|
||||
// body — the equal-to-each-other check alone would still pass in that case.
|
||||
// Pinned to EndpointExtensions.NotApprovedMessage's JSON shape.
|
||||
private const string NotApprovedBodyJson = """{"error":"API key not approved for this method"}""";
|
||||
// Pinned to EndpointExtensions.NotApprovedMessage's JSON shape. Task 5 (C3): the
|
||||
// error body now additionally carries the machine-readable "code" field; both 403
|
||||
// branches still emit this identical body, preserving enumeration-safety.
|
||||
private const string NotApprovedBodyJson =
|
||||
"""{"error":"API key not approved for this method","code":"NOT_APPROVED"}""";
|
||||
|
||||
// Each test gets its own throwaway SQLite database so seeded keys never leak
|
||||
// between tests; the file is deleted on Dispose.
|
||||
@@ -341,6 +345,45 @@ public sealed class EndpointExtensionsTests : IDisposable
|
||||
Assert.Equal(NotApprovedBodyJson, notInScopeBody);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ErrorBodies_CarryMachineReadableCode()
|
||||
{
|
||||
// Task 5 (C3): every error body carries a machine-readable "code" field for
|
||||
// external integrators — UNAUTHORIZED (401), INVALID_JSON (400),
|
||||
// VALIDATION_FAILED (400).
|
||||
var method = SeedMethod(1, "needsParam", "return Parameters[\"value\"];",
|
||||
"""[{"name":"value","type":"Integer","required":true}]""");
|
||||
|
||||
using var host = await BuildHostAsync(method);
|
||||
var token = await SeedKeyAsync(host, "key1", "caller", new[] { "needsParam" });
|
||||
var client = host.GetTestClient();
|
||||
|
||||
// (a) missing credential → UNAUTHORIZED
|
||||
var unauth = await client.SendAsync(new HttpRequestMessage(HttpMethod.Post, "/api/needsParam")
|
||||
{
|
||||
Content = new StringContent("{}", Encoding.UTF8, "application/json"),
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, unauth.StatusCode);
|
||||
Assert.Equal("UNAUTHORIZED", await ReadCodeAsync(unauth));
|
||||
|
||||
// (b) in-scope but invalid JSON → INVALID_JSON
|
||||
var badJson = await client.SendAsync(BuildPost("needsParam", "{ not json", token));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, badJson.StatusCode);
|
||||
Assert.Equal("INVALID_JSON", await ReadCodeAsync(badJson));
|
||||
|
||||
// (c) missing required parameter → VALIDATION_FAILED
|
||||
var missing = await client.SendAsync(BuildPost("needsParam", "{}", token));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, missing.StatusCode);
|
||||
Assert.Equal("VALIDATION_FAILED", await ReadCodeAsync(missing));
|
||||
}
|
||||
|
||||
private static async Task<string?> ReadCodeAsync(HttpResponseMessage response)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
return doc.RootElement.TryGetProperty("code", out var code) ? code.GetString() : null;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidJsonBody_Returns400()
|
||||
{
|
||||
|
||||
@@ -97,6 +97,32 @@ public class InboundScriptExecutorTests
|
||||
Assert.Equal("Internal script error", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RoutedSiteUnreachable_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.
|
||||
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));
|
||||
var route = new RouteHelper(locator, router);
|
||||
|
||||
var method = new ApiMethod("routed", "return await Route.To(\"X\").Call(\"s\");")
|
||||
{ Id = 7, TimeoutSeconds = 10 };
|
||||
|
||||
var result = await _executor.ExecuteAsync(
|
||||
method, new Dictionary<string, object?>(), route, TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("SITE_UNREACHABLE", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandlerTimesOut_ReturnsTimeoutError()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user