fix(inbound): 415 guard and lenient JSON parse cover chunked (no Content-Length) bodies (plan R2-06 T6)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user