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:
Joseph Doherty
2026-07-10 04:29:55 -04:00
parent 761729b5d0
commit b5e80d4c00
8 changed files with 248 additions and 30 deletions
@@ -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()
{