diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 84fe978..c018c0c 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -11,8 +11,9 @@ on: env: CONFIGURATION: Release DOTNET_VERSION: 11.0.x + OUTPUT_FOLDER: net11.0 jobs: - build-and-deploy: + build-and-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -20,10 +21,6 @@ jobs: uses: actions/setup-dotnet@v5 with: dotnet-version: ${{ env.DOTNET_VERSION }} - - name: Install Node.js - uses: actions/setup-node@v6 - with: - node-version: 26.x - name: Install Package libmsquic run: | sudo apt update @@ -38,10 +35,34 @@ jobs: dotnet test ./tests/TestWebApplication.Tests --no-build -c ${{ env.CONFIGURATION }} dotnet test ./tests/CHttp.Api.Tests --no-build -c ${{ env.CONFIGURATION }} dotnet test ./tests/CHttpServer.Tests --no-build -c ${{ env.CONFIGURATION }} + + build-and-test-node: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Install Node.js + uses: actions/setup-node@v6 + with: + node-version: 26.x + - name: Install Package libmsquic + run: | + sudo apt update + sudo apt install -y libmsquic + shell: sh - name: Publish VSCE run: | pushd ./src/VSCodeExt/ npm install npm run vsce-deploy-win-x64 npm run vsce-deploy-linux-x64 - popd \ No newline at end of file + popd + - name: Browser CHttp Server Tests + run: | + dotnet build ./tests/CHttpServer.BrowserTests -c ${{ env.CONFIGURATION }} + ./tests/CHttpServer.BrowserTests/bin/${{ env.CONFIGURATION }}/${{ env.OUTPUT_FOLDER }}/playwright.ps1 install --with-deps chromium + dotnet test ./tests/CHttpServer.BrowserTests -c ${{ env.CONFIGURATION }} + shell: pwsh \ No newline at end of file diff --git a/CHttpTools.slnx b/CHttpTools.slnx index eb2d6f5..2306fd8 100644 --- a/CHttpTools.slnx +++ b/CHttpTools.slnx @@ -1,8 +1,9 @@ - + + diff --git a/src/CHttpServer/CHttpServer/Http3/Http3Connection.cs b/src/CHttpServer/CHttpServer/Http3/Http3Connection.cs index 3b8c935..de6f605 100644 --- a/src/CHttpServer/CHttpServer/Http3/Http3Connection.cs +++ b/src/CHttpServer/CHttpServer/Http3/Http3Connection.cs @@ -174,6 +174,10 @@ private async Task TryWriteGoAwayAsync() { // Connection already aborted by the client. } + catch (QuicException ex) + { + Debug.WriteLine(ex); + } } private async Task HandleStreamAsync( diff --git a/src/CHttpServer/CHttpServer/RequestHeaderCollection.cs b/src/CHttpServer/CHttpServer/RequestHeaderCollection.cs index cdb566a..cbe20a6 100644 --- a/src/CHttpServer/CHttpServer/RequestHeaderCollection.cs +++ b/src/CHttpServer/CHttpServer/RequestHeaderCollection.cs @@ -139,17 +139,12 @@ private bool TrySetKnownHeader(ReadOnlySpan rawKey, ReadOnlySpan raw { Span utf16Value = stackalloc char[rawValue.Length]; Encoding.Latin1.GetChars(rawValue, utf16Value); - if (_hostValue.Count > 0 && utf16Value.SequenceEqual(_hostValue[0].AsSpan())) - { - key = "Host"; - value = _hostValue; - return true; - } + if (_hostValue.Count == 0 || !utf16Value.SequenceEqual(_hostValue[0].AsSpan())) + _hostValue = new StringValues(utf16Value.ToString()); - _hostValue = new StringValues(utf16Value.ToString()); - _isHostValueSet = true; key = "Host"; value = _hostValue; + _isHostValueSet = true; return true; } @@ -164,12 +159,9 @@ private bool TrySetKnownHeader(string key, ReadOnlySpan rawValue) { Span utf16Value = stackalloc char[rawValue.Length]; Encoding.Latin1.GetChars(rawValue, utf16Value); - if (_hostValue.Count > 0 && utf16Value.SequenceEqual(_hostValue[0].AsSpan())) - { - key = "Host"; - return true; - } - _hostValue = new StringValues(utf16Value.ToString()); + if (_hostValue.Count == 0 || !utf16Value.SequenceEqual(_hostValue[0].AsSpan())) + _hostValue = new StringValues(utf16Value.ToString()); + _isHostValueSet = true; return true; } diff --git a/tests/CHttp.Tests/testCert.pfx b/tests/CHttp.Tests/testCert.pfx index 5cf1513..8914c5e 100644 Binary files a/tests/CHttp.Tests/testCert.pfx and b/tests/CHttp.Tests/testCert.pfx differ diff --git a/tests/CHttpServer.BrowserTests/CHttpServer.BrowserTests.csproj b/tests/CHttpServer.BrowserTests/CHttpServer.BrowserTests.csproj new file mode 100644 index 0000000..ff3f69f --- /dev/null +++ b/tests/CHttpServer.BrowserTests/CHttpServer.BrowserTests.csproj @@ -0,0 +1,33 @@ + + + + net11.0 + enable + enable + false + true + true + true + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/CHttpServer.BrowserTests/Http2BrowserTests.cs b/tests/CHttpServer.BrowserTests/Http2BrowserTests.cs new file mode 100644 index 0000000..029a13a --- /dev/null +++ b/tests/CHttpServer.BrowserTests/Http2BrowserTests.cs @@ -0,0 +1,67 @@ +using Microsoft.Playwright; + +namespace CHttpServer.BrowserTests; + +public class Http2BrowserTests(Http2TestFixture testFixture) : IClassFixture +{ + private readonly Http2TestFixture _fixture = testFixture; + private readonly string _url = $"https://127.0.0.1:{testFixture.Port}"; + + [Fact] + public async Task Http2Protocol() + { + var page = await _fixture.Browser.NewPageAsync(); + await page.GotoAsync($"{_url}/protocol"); + Assert.Contains("HTTP/2", await page.ContentAsync()); + await page.CloseAsync(); + } + + [Fact] + public async Task Sse() + { + var page = await _fixture.Browser.NewPageAsync(); + await page.GotoAsync($"{_url}/html"); + await page.ClickAsync("body > button:nth-child(3)"); + var sse = await page.WaitForSelectorAsync("#sse", new PageWaitForSelectorOptions() { State = WaitForSelectorState.Visible, Timeout = 10_000 }); + Assert.NotNull(sse); + var content = await sse.InnerHTMLAsync(); + Assert.Contains("sse 0", content); + Assert.Contains("sse 1", content); + await page.CloseAsync(); + } + + [Fact] + public async Task FormPost() + { + var page = await _fixture.Browser.NewPageAsync(); + await page.GotoAsync($"{_url}/html"); + + // Intercept all requests ("**/*" is a glob pattern for everything) + await page.RouteAsync("**/*", async route => + { + // Let the request continue normally + await route.ContinueAsync(); + }); + + await page.RunAndWaitForResponseAsync(async () => + { + await page.ClickAsync("body > form > button"); + }, $"{_url}/protocol"); + Assert.Contains("HTTP/2", await page.ContentAsync()); + await page.CloseAsync(); + } + + [Fact] + public async Task GetFetchJs() + { + var page = await _fixture.Browser.NewPageAsync(); + await page.GotoAsync($"{_url}/html"); + await page.ClickAsync("body > button:nth-child(2)"); + var output = await page.WaitForSelectorAsync("#output", new PageWaitForSelectorOptions() { State = WaitForSelectorState.Visible, Timeout = 10_000 }); + Assert.NotNull(output); + var content = await output.InnerHTMLAsync(); + Assert.Contains("\"some content\"", content); + await page.CloseAsync(); + } +} + diff --git a/tests/CHttpServer.BrowserTests/Http2TestFixture.cs b/tests/CHttpServer.BrowserTests/Http2TestFixture.cs new file mode 100644 index 0000000..ae23539 --- /dev/null +++ b/tests/CHttpServer.BrowserTests/Http2TestFixture.cs @@ -0,0 +1,37 @@ +using System.Runtime.Versioning; +using CHttpServer.Tests; +using Microsoft.Playwright; + +namespace CHttpServer.BrowserTests; + +public sealed class Http2TestFixture : IAsyncDisposable +{ + internal int Port => 7294; + + public TestServer Server { get; private set; } + + public IPlaywright PlaywrightHost { get; private set; } + + public IBrowserContext Browser { get; private set; } + + [SupportedOSPlatform("linux")] + [SupportedOSPlatform("windows")] + public Http2TestFixture() + { + Server = new TestServer(); + Server.RunAsync(Port, false, useHttp3: false).GetAwaiter().GetResult(); + PlaywrightHost = Playwright.CreateAsync().GetAwaiter().GetResult(); + Browser = PlaywrightHost.Chromium.LaunchPersistentContextAsync("/tmp/chrome-profile-integrationtest", new() + { + Headless = true, + Args = ["--ignore-certificate-errors-spki-list=5QveYGg8xaCnnZWvkC9Y6v9lQVmF2BCozvds6Cn6F6k="] + }).GetAwaiter().GetResult(); + } + + public async ValueTask DisposeAsync() + { + await Browser.DisposeAsync(); + PlaywrightHost.Dispose(); + await Server.DisposeAsync(); + } +} diff --git a/tests/CHttpServer.BrowserTests/Http3BrowserTests.cs b/tests/CHttpServer.BrowserTests/Http3BrowserTests.cs new file mode 100644 index 0000000..27c425e --- /dev/null +++ b/tests/CHttpServer.BrowserTests/Http3BrowserTests.cs @@ -0,0 +1,68 @@ +using Microsoft.Playwright; + +namespace CHttpServer.BrowserTests; + +public class Http3BrowserTests(Http3TestFixture testFixture) : IClassFixture +{ + private readonly Http3TestFixture _fixture = testFixture; + private readonly string _url = $"https://127.0.0.1:{testFixture.Port}"; + + [Fact] + public async Task Http3Protocol() + { + for (int i = 0; i < 10; i++) + { + var page = await _fixture.Browser.NewPageAsync(); + await page.GotoAsync($"{_url}/protocol"); + Assert.Contains("HTTP/3", await page.ContentAsync()); + await page.CloseAsync(); + } + } + + [Fact] + public async Task Sse() + { + var page = await _fixture.Browser.NewPageAsync(); + await page.GotoAsync($"{_url}/html"); + await page.ClickAsync("body > button:nth-child(3)"); + var sse = await page.WaitForSelectorAsync("#sse", new PageWaitForSelectorOptions() { State = WaitForSelectorState.Visible, Timeout = 10_000 }); + Assert.NotNull(sse); + var content = await sse.InnerHTMLAsync(); + Assert.Contains("sse 0", content); + Assert.Contains("sse 1", content); + await page.CloseAsync(); + } + + [Fact] + public async Task FormPost() + { + for (int i = 0; i < 10; i++) + { + var page = await _fixture.Browser.NewPageAsync(); + await page.GotoAsync($"{_url}/html"); + await page.RunAndWaitForResponseAsync(async () => + { + await page.ClickAsync("body > form > button"); + }, $"{_url}/protocol"); + Assert.Contains("HTTP/3", await page.ContentAsync()); + await page.CloseAsync(); + } + } + + [Fact] + public async Task GetFetchJs() + { + for (int i = 0; i < 10; i++) + { + var page = await _fixture.Browser.NewPageAsync(); + await page.GotoAsync($"{_url}/html"); + await page.ClickAsync("body > button:nth-child(2)"); + var output = await page.WaitForSelectorAsync("#output", new PageWaitForSelectorOptions() { State = WaitForSelectorState.Visible, Timeout = 10_000 }); + Assert.NotNull(output); + var content = await output.InnerHTMLAsync(); + Assert.Contains("\"some content\"", content); + await page.CloseAsync(); + } + } +} + diff --git a/tests/CHttpServer.BrowserTests/Http3TestFixture.cs b/tests/CHttpServer.BrowserTests/Http3TestFixture.cs new file mode 100644 index 0000000..1c6e8c9 --- /dev/null +++ b/tests/CHttpServer.BrowserTests/Http3TestFixture.cs @@ -0,0 +1,37 @@ +using System.Runtime.Versioning; +using CHttpServer.Tests; +using Microsoft.Playwright; + +namespace CHttpServer.BrowserTests; + +public sealed class Http3TestFixture : IAsyncDisposable +{ + internal int Port => 7295; + + public TestServer Server { get; private set; } + + public IPlaywright PlaywrightHost { get; private set; } + + public IBrowserContext Browser { get; private set; } + + [SupportedOSPlatform("linux")] + [SupportedOSPlatform("windows")] + public Http3TestFixture() + { + Server = new TestServer(); + Server.RunAsync(Port, false, useHttp3: true).GetAwaiter().GetResult(); + PlaywrightHost = Playwright.CreateAsync().GetAwaiter().GetResult(); + Browser = PlaywrightHost.Chromium.LaunchPersistentContextAsync("/tmp/chrome-profile-integrationtest", new() + { + Headless = true, + Args = [$"--origin-to-force-quic-on=127.0.0.1:{Port}", "--ignore-certificate-errors-spki-list=5QveYGg8xaCnnZWvkC9Y6v9lQVmF2BCozvds6Cn6F6k="] + }).GetAwaiter().GetResult(); + } + + public async ValueTask DisposeAsync() + { + await Browser.DisposeAsync(); + PlaywrightHost.Dispose(); + await Server.DisposeAsync(); + } +} diff --git a/tests/CHttpServer.Tests/Http3/Http3IntegrationTests.cs b/tests/CHttpServer.Tests/Http3/Http3IntegrationTests.cs index e0e15c4..699aaa8 100644 --- a/tests/CHttpServer.Tests/Http3/Http3IntegrationTests.cs +++ b/tests/CHttpServer.Tests/Http3/Http3IntegrationTests.cs @@ -1,5 +1,4 @@ using System.Net; -using System.Net.Http.Json; using System.Text; namespace CHttpServer.Tests.Http3; @@ -347,7 +346,7 @@ public async Task TestServerSideEvents() var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken); Assert.True(response.IsSuccessStatusCode); var content = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - Assert.Equal("data: some content\n\ndata: some content\n\n", content); + Assert.Equal("data: sse 0\n\ndata: sse 1\n\n", content); } } diff --git a/tests/CHttpServer.Tests/TestServer.cs b/tests/CHttpServer.Tests/TestServer.cs index 1f9442f..983f624 100644 --- a/tests/CHttpServer.Tests/TestServer.cs +++ b/tests/CHttpServer.Tests/TestServer.cs @@ -1,12 +1,14 @@ -using System.Security.Cryptography.X509Certificates; +using System.Net; +using System.Security.Cryptography.X509Certificates; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace CHttpServer.Tests; -public class TestServer : IAsyncDisposable, IDisposable +public sealed class TestServer : IAsyncDisposable, IDisposable { private WebApplication? _app; private bool _isDisposed; @@ -30,8 +32,8 @@ public Task RunAsync(int port = 7222, bool usePriority = false, bool useHttp3 = //{ // o.Listen(IPAddress.Loopback, port, lo => // { - // lo.UseHttps(); - // lo.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http3; + // lo.UseHttps(X509CertificateLoader.LoadPkcs12FromFile("testCert.pfx", "testPassword")); + // lo.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http1AndHttp2AndHttp3; // }); //}); _app = builder.Build(); @@ -39,6 +41,10 @@ public Task RunAsync(int port = 7222, bool usePriority = false, bool useHttp3 = { return "TypedResults.NoContent()"; }); + _app.MapGet("/protocol", (HttpContext ctx) => + { + return ctx.Request.Protocol; + }); _app.MapGet("/nocontent", () => { return TypedResults.NoContent(); @@ -90,7 +96,7 @@ async IAsyncEnumerable GetStream() foreach (var i in Enumerable.Range(0, 2)) { await Task.Delay(1000); - yield return "some content"; + yield return $"sse {i}"; } } return Results.ServerSentEvents(GetStream()); @@ -174,7 +180,54 @@ async IAsyncEnumerable GetStream() ctx.Response.StatusCode = 204; } }); - return _app.StartAsync(); //.RunAsync(); + _app.MapPost("/posthiddenfield", ([FromForm(Name = "field")] string field) => + { + return TypedResults.LocalRedirect("/protocol"); + }).DisableAntiforgery(); + _app.MapGet("/html", async (HttpContext ctx) => + { + var baseUrl = ctx.Request.Host; + await ctx.Response.WriteAsync($$""" + + + +
+ + +
+ + + + + + + + + """); + }); + return _app.StartAsync(); } public async ValueTask DisposeAsync() diff --git a/tests/dev_cert.ps1 b/tests/dev_cert.ps1 new file mode 100644 index 0000000..e24b931 --- /dev/null +++ b/tests/dev_cert.ps1 @@ -0,0 +1,28 @@ +# Define the certificate parameters +$certParams = @{ + Subject = "CN=localhost" + Type = "SSLServerAuthentication" # Adds Server EKU (1.3.6.1.5.5.7.3.1) + CertStoreLocation = "Cert:\LocalMachine\My" # Installs to the machine's Personal store + + # Manually define SANs via OID 2.5.29.17 to ensure IP addresses are formatted correctly + TextExtension = @( + "2.5.29.17={text}DNS=localhost&DNS=*.dev.localhost&DNS=*.dev.internal&DNS=host.docker.internal&DNS=host.containers.internal&IPAddress=127.0.0.1&IPAddress=0000:0000:0000:0000:0000:0000:0000:0001" + ) + + # Key & Security Properties + KeyAlgorithm = "RSA" + KeyLength = 2048 + HashAlgorithm = "SHA256" + KeyExportPolicy = "Exportable" # Set to exportable so you can use it inside Docker/containers + + # Modern browsers (Chrome, Safari, Edge) reject certificates valid for more than 398 days. + # We set this to 397 days to maintain maximum compatibility. + NotAfter = (Get-Date).AddDays(397) +} + +# Generate the certificate +$cert = New-SelfSignedCertificate @certParams + +# Output details to console +Write-Host "Certificate generated successfully!" -ForegroundColor Green +$cert | Format-List Subject, Thumbprint, NotAfter \ No newline at end of file