using System.Net;
using NewLife;
using NewLife.Data;
using NewLife.Http;
using NewLife.Remoting;
using Xunit;
namespace XUnitTest.Http;
/// <summary>HttpResponse 解析与构建测试</summary>
public class HttpResponseTests
{
[Fact]
public void Parse_Response_Ok()
{
var body = "OK".GetBytes();
var header = """
HTTP/1.1 200 OK
Content-Length: 2
Content-Type: text/plain
""";
var pk = new ArrayPacket(header.GetBytes()) { Next = (ArrayPacket)body };
var resp = new HttpResponse();
var ok = resp.Parse(pk);
Assert.True(ok);
Assert.Equal("1.1", resp.Version);
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
Assert.Equal("OK", resp.StatusDescription);
Assert.Equal(2, resp.ContentLength);
Assert.Equal("text/plain", resp.ContentType);
Assert.Equal(2, resp.BodyLength);
Assert.True(resp.IsCompleted);
}
[Fact]
public void Parse_Response_ErrorWithoutBody_UsesDescriptionAsBodyOnBuild()
{
var resp = new HttpResponse
{
StatusCode = HttpStatusCode.BadRequest,
StatusDescription = "Bad Request",
};
var pk = resp.Build();
var text = pk.GetSpan().ToStr();
Assert.Contains("HTTP/1.1 400 Bad Request\r\n", text);
Assert.Contains("Content-Length: 11\r\n", text); // Body 自动使用描述
Assert.EndsWith("\r\nBad Request", text);
}
[Fact(DisplayName = "Build 状态描述含CRLF:不裂出伪造响应头")]
public void Build_StatusDescription_Crlf_NotInjected()
{
// 异常消息等来源可能夹带 CR/LF:直接写进状态行会让接收方解析出额外头部(响应拆分)
var resp = new HttpResponse
{
StatusCode = HttpStatusCode.InternalServerError,
StatusDescription = "boom\r\nX-Injected: yes",
};
var text = resp.Build().ToStr();
var headEnd = text.IndexOf("\r\n\r\n", StringComparison.Ordinal);
var head = text[..headEnd];
var firstLine = head[..head.IndexOf("\r\n", StringComparison.Ordinal)];
// 状态行只剩一行,CR/LF 被压成空格;注入的头部只出现在**体**里(体不是头部,不构成响应拆分)
Assert.StartsWith("HTTP/1.1 500 boom", firstLine);
Assert.Contains("X-Injected: yes", firstLine);
Assert.DoesNotContain("\r\nX-Injected", head);
}
[Fact(DisplayName = "Build 头部值含CRLF:不裂出伪造响应头(字段名同样清洗)")]
public void Build_HeaderValue_Crlf_NotInjected()
{
var resp = new HttpResponse();
resp.Headers["X-Test"] = "a\r\nX-Evil: 1\r\nContent-Length: 0";
resp.Headers["X-Na\r\nme"] = "v";
var text = resp.Build().ToStr();
var headEnd = text.IndexOf("\r\n\r\n", StringComparison.Ordinal);
var head = text[..headEnd];
Assert.DoesNotContain("\r\nX-Evil", head);
Assert.DoesNotContain("X-Na\r\n", head);
Assert.Contains("X-Test: a X-Evil: 1 Content-Length: 0\r\n", head);
}
[Fact(DisplayName = "Build 头部无控制字符:原样输出(清洗不改动正常头部)")]
public void Build_HeaderWithoutControlChars_Unchanged()
{
var resp = new HttpResponse();
resp.Headers["X-Test"] = "正常值 with SP and\tTAB";
var text = resp.Build().ToStr();
Assert.Contains("X-Test: 正常值 with SP and\tTAB\r\n", text);
}
[Fact]
public void Build_Response_SetsContentLengthZeroWhenNoBody()
{
var resp = new HttpResponse();
var pk = resp.Build();
var text = pk.ToStr();
Assert.Contains("HTTP/1.1 200 OK\r\n", text);
Assert.Contains("Content-Length: 0\r\n", text);
}
[Fact(DisplayName = "Build 链式跨段体:响应体全链写入")]
public void Build_Response_ChainedBody()
{
IPacket chain = new ArrayPacket("hello ".GetBytes());
chain.Append(new ArrayPacket("world".GetBytes()));
var resp = new HttpResponse { Body = chain };
var pk = resp.Build();
var text = pk.ToStr();
Assert.Contains("Content-Length: 11\r\n", text);
Assert.EndsWith("hello world", text);
}
[Theory(DisplayName = "Build 1xx/204/304 无实体响应:不声明 Content-Length")]
[InlineData(HttpStatusCode.Continue)]
[InlineData(HttpStatusCode.SwitchingProtocols)]
[InlineData(HttpStatusCode.NoContent)]
[InlineData(HttpStatusCode.NotModified)]
public void Build_NoEntityStatus_OmitsContentLength(HttpStatusCode code)
{
var resp = new HttpResponse { StatusCode = code };
var pk = resp.Build();
var text = pk.ToStr();
// RFC 7230 §3.3.2:无实体状态码不得带 Content-Length
Assert.DoesNotContain("Content-Length", text);
Assert.StartsWith($"HTTP/1.1 {(Int32)code} ", text);
}
[Fact(DisplayName = "Build 204带主体:实体不写入报文")]
public void Build_NoEntityStatus_IgnoresBody()
{
// 无实体状态码即使被赋了 Body 也不得把实体写进报文:头部省略了 Content-Length,
// 实体字节会让头部与实体自相矛盾(严格解析的客户端会把这些字节当作下一个响应的开头)
var resp = new HttpResponse
{
StatusCode = HttpStatusCode.NoContent,
Body = new ArrayPacket("ignored".GetBytes()),
};
using var pk = resp.Build();
var text = pk.ToStr();
Assert.DoesNotContain("ignored", text);
Assert.DoesNotContain("Content-Length", text);
Assert.EndsWith("\r\n\r\n", text);
}
[Theory(DisplayName = "Build 204/304带状态描述:不把描述当实体")]
[InlineData(HttpStatusCode.NoContent)]
[InlineData(HttpStatusCode.NotModified)]
public void Build_NoEntityStatus_DoesNotUseStatusDescriptionAsBody(HttpStatusCode code)
{
// 非成功状态码会用状态描述补齐实体,但 204/304 不得携带实体
var resp = new HttpResponse { StatusCode = code, StatusDescription = "Not Modified" };
using var pk = resp.Build();
var text = pk.ToStr();
Assert.StartsWith($"HTTP/1.1 {(Int32)code} Not Modified\r\n", text);
Assert.EndsWith("\r\n\r\n", text);
Assert.DoesNotContain("Content-Length", text);
}
[Fact(DisplayName = "Build 200 无体响应:仍声明 Content-Length 0")]
public void Build_OkWithoutBody_KeepsZeroContentLength()
{
// 回归护栏:204/304 的特例不得泛化到普通状态码
var resp = new HttpResponse();
var pk = resp.Build();
var text = pk.ToStr();
Assert.Contains("Content-Length: 0\r\n", text);
}
[Fact(DisplayName = "Build 1xx响应:状态描述不当作实体,报文只剩头部")]
public void Build_InformationalStatus_UsesHeaderOnly()
{
// 非成功状态码会用状态描述补齐实体,但 1xx 属无实体响应(RFC 7230 §3.3.2)
var resp = new HttpResponse { StatusCode = HttpStatusCode.Continue, StatusDescription = "Continue" };
using var pk = resp.Build();
Assert.Equal("HTTP/1.1 100 Continue\r\n\r\n", pk.ToStr());
}
[Theory(DisplayName = "BuildHeaderPacket 1xx/204/304 响应:不声明 Content-Length")]
[InlineData(HttpStatusCode.Continue)]
[InlineData(HttpStatusCode.SwitchingProtocols)]
[InlineData(HttpStatusCode.NoContent)]
[InlineData(HttpStatusCode.NotModified)]
public void BuildHeaderPacket_NoEntityStatus_OmitsContentLength(HttpStatusCode code)
{
// HEAD 与流式响应走 BuildHeaderPacket 而非 Build,两条路径必须同规则
var resp = new HttpResponse { StatusCode = code };
using var pk = resp.BuildHeaderPacket(resp.Body?.Total ?? 0);
var text = pk.ToStr();
Assert.DoesNotContain("Content-Length", text);
}
[Fact]
public void SetResult_VariousTypes()
{
var resp1 = new HttpResponse();
resp1.SetResult("hello");
Assert.Equal("text/html", resp1.ContentType);
Assert.Equal("hello", resp1.Body.ToStr());
var resp2 = new HttpResponse();
resp2.SetResult(new { name = "Stone" });
Assert.Equal("application/json", resp2.ContentType);
Assert.Contains("\"name\":\"Stone\"", resp2.Body.ToStr());
var resp3 = new HttpResponse();
var bytes = new Byte[] { 1, 2, 3 };
resp3.SetResult(bytes);
Assert.Equal("application/octet-stream", resp3.ContentType);
Assert.Equal(bytes, resp3.Body.ReadBytes());
var resp4 = new HttpResponse();
var ex = new ApiException(500, "failed");
resp4.SetResult(ex);
Assert.Equal(HttpStatusCode.InternalServerError, resp4.StatusCode); // ApiException.Code 500 cast to HttpStatusCode
Assert.Equal("failed", resp4.StatusDescription);
}
[Fact(DisplayName = "流式响应_整包构建_物化流并释放")]
public void BodyStream_Build_Materializes()
{
var ms = new MemoryStream("stream-body".GetBytes());
var resp = new HttpResponse { BodyStream = ms };
using var pk = resp.Build();
var text = pk.ToStr();
Assert.Contains("Content-Length: 11\r\n", text);
Assert.EndsWith("stream-body", text);
// 物化后流已释放、BodyStream 清空;二次构建可重复
Assert.Null(resp.BodyStream);
Assert.Throws<ObjectDisposedException>(() => ms.ReadByte());
using var pk2 = resp.Build();
Assert.Equal(pk.Total, pk2.Total);
}
[Fact]
public void Valid_ThrowsOnNonOk()
{
var resp = new HttpResponse { StatusCode = HttpStatusCode.NotFound, StatusDescription = "missing" };
var ex = Assert.Throws<Exception>(() => resp.Valid());
Assert.Equal("missing", ex.Message);
}
[Fact]
public void Response_Parse_InvalidFirstLine()
{
var raw = """
NOTHTTP 400 ERR
Content-Length:0
""".GetBytes();
var pk = new ArrayPacket(raw);
var resp = new HttpResponse();
var ok = resp.Parse(pk);
Assert.False(ok);
}
}
|