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:
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user