fix(inbound): 415 guard and lenient JSON parse cover chunked (no Content-Length) bodies (plan R2-06 T6)

This commit is contained in:
Joseph Doherty
2026-07-13 10:01:22 -04:00
parent fb9dc7ad14
commit 91bde0fae6
3 changed files with 100 additions and 4 deletions
@@ -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;
}
}
}