using System.Text;
using Sunfish.Http;
using Sunfish.Web;
namespace Sunfish.Tests;
public class HtmlBuilderTests
{
[Fact]
public void ClassicModeIsPureAscii()
{
string escaped = HtmlBuilder.Escape("Año <ñ> & \"€\" ő 日", classic: true);
Assert.Equal("Año <ñ> & "?" o ?", escaped);
Assert.All(escaped, c => Assert.True(c < 0x80));
}
[Fact]
public void ModernModeKeepsUnicode() =>
Assert.Equal("Año <日>", HtmlBuilder.Escape("Año <日>", classic: false));
[Fact]
public void UrlSegmentsAreUtf8Percent() =>
Assert.Equal("Fotos%20del%20a%C3%B1o.jpg", HtmlBuilder.UrlSegment("Fotos del año.jpg"));
}
public class ClientProfileTests
{
[Theory]
[InlineData("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/130 Safari/537.36", ClientKind.Modern)]
[InlineData("Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0", ClientKind.Modern)]
[InlineData("Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 Safari/605.1.15", ClientKind.Modern)]
[InlineData("Mozilla/4.0 (compatible; MSIE 5.5; Windows 98)", ClientKind.Classic)]
[InlineData("Mozilla/3.01 (Win95; I)", ClientKind.Classic)]
[InlineData("Mozilla/3.0 (Win16; I)", ClientKind.Win16)]
[InlineData("Mozilla/2.0 (compatible; MSIE 3.0; Windows 3.1)", ClientKind.Win16)]
[InlineData("Mozilla/3.0 (Macintosh; I; PPC)", ClientKind.Mac)]
[InlineData("Mozilla/4.0 (compatible; MSIE 5.0; Mac_PowerPC)", ClientKind.Mac)]
[InlineData("Lynx/2.8.9rel.1 libwww-FM/2.14", ClientKind.Text)]
[InlineData("xChaos_Arachne/5.1.89;GPL,386+", ClientKind.Dos)]
[InlineData(null, ClientKind.Classic)]
public void DetectsFromUserAgent(string? userAgent, ClientKind expected)
{
var profile = ClientProfile.Detect(userAgent, null);
Assert.Equal(expected, profile.Kind);
Assert.False(profile.Forced);
}
[Fact]
public void QueryForcesTheProfile()
{
var profile = ClientProfile.Detect("Mozilla/5.0 (X11) Gecko/20100101 Firefox/130.0", "dos");
Assert.Equal(ClientKind.Dos, profile.Kind);
Assert.True(profile.Forced);
Assert.Equal(ClientKind.Modern, ClientProfile.Detect("Mozilla/5.0 Gecko/1", "nonsense").Kind);
}
}
public class ByteRangeTests
{
[Theory]
[InlineData("bytes=0-99", 1000, 0, 99)]
[InlineData("bytes=500-", 1000, 500, 999)]
[InlineData("bytes=-100", 1000, 900, 999)]
[InlineData("bytes=900-5000", 1000, 900, 999)]
[InlineData("bytes=-5000", 1000, 0, 999)]
public void ValidRanges(string header, long size, long start, long end)
{
Assert.Equal(ByteRange.ParseResult.Valid, ByteRange.TryParse(header, size, out var range));
Assert.Equal(new ByteRange(start, end), range);
}
[Theory]
[InlineData(null)]
[InlineData("items=0-1")]
[InlineData("bytes=0-1,5-6")]
[InlineData("bytes=9-3")]
[InlineData("bytes=a-b")]
public void IgnoredRanges(string? header) =>
Assert.Equal(ByteRange.ParseResult.None, ByteRange.TryParse(header, 1000, out _));
[Fact]
public void RangeBeyondTheEnd() =>
Assert.Equal(ByteRange.ParseResult.Unsatisfiable, ByteRange.TryParse("bytes=1000-", 1000, out _));
}
public class HttpRequestReaderTests
{
private static async Task<HttpRequest?> Read(string raw)
{
var reader = new HttpRequestReader(new MemoryStream(Encoding.Latin1.GetBytes(raw)));
return await reader.ReadRequestAsync(() => Task.CompletedTask, CancellationToken.None);
}
[Fact]
public async Task ParsesHttp11()
{
var r = await Read("GET /a%20b?x=1&y HTTP/1.1\r\nHost: h\r\nUser-Agent: test\r\n\r\n");
Assert.NotNull(r);
Assert.Equal("GET", r.Method);
Assert.Equal("/a%20b", r.Path);
Assert.Equal("1", r.GetQuery("x"));
Assert.True(r.HasQuery("y"));
Assert.True(r.IsHttp11OrLater);
Assert.True(r.WantsKeepAlive);
Assert.Equal("test", r.Headers["user-agent"]);
}
[Fact]
public async Task AcceptsBareLineFeedsAndNoHost()
{
var r = await Read("\n\nGET / HTTP/1.0\nAccept: */*\n\n");
Assert.NotNull(r);
Assert.Equal("/", r.Path);
Assert.False(r.WantsKeepAlive);
Assert.Null(r.Headers["Host"]);
}
[Fact]
public async Task ParsesHttp09()
{
var r = await Read("GET /index.html\r\n");
Assert.NotNull(r);
Assert.True(r.IsHttp09);
Assert.Equal("/index.html", r.Path);
}
[Fact]
public async Task FoldedHeadersAndJunkLines()
{
var r = await Read("GET / HTTP/1.0\r\nX-Long: a\r\n b\r\nbasura sin dos puntos\r\n\r\n");
Assert.Equal("a b", r!.Headers["X-Long"]);
}
[Fact]
public async Task AbsoluteTargetAndRawHighBytes()
{
var r = await Read("GET http://host:8080/año/?q HTTP/1.0\r\n\r\n");
Assert.Equal("/a%F1o/", r!.Path);
Assert.Equal("q", r.QueryString);
}
[Theory]
[InlineData("POST /x\r\n", 400)]
[InlineData("GET / HTTP/2.0\r\n\r\n", 505)]
[InlineData("GET / HTTP/x\r\n\r\n", 400)]
public async Task RejectsMalformedRequests(string raw, int status)
{
var e = await Assert.ThrowsAsync<HttpParseException>(() => Read(raw));
Assert.Equal(status, e.Status);
}
[Fact]
public async Task ReadsChunkedBodies()
{
var r = await Read("PUT /x HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n6;ext=1\r\n world\r\n0\r\n\r\n");
var ms = new MemoryStream();
await r!.Body.CopyToAsync(ms);
Assert.Equal("hello world", Encoding.ASCII.GetString(ms.ToArray()));
}
[Fact]
public async Task ReturnsNullOnCleanClose() => Assert.Null(await Read(""));
}