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
@@ -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()
{