diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 32d790c..27da76e 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -33,7 +33,11 @@ jobs: run: dotnet build CHttpTools.slnx -c ${{ env.CONFIGURATION }} - name: Test run: | - dotnet test --no-build -c ${{ env.CONFIGURATION }} + dotnet test ./tests/CHttp.Tests --no-build -c ${{ env.CONFIGURATION }} + dotnet test ./tests/CHttpExecutor.Tests --no-build -c ${{ env.CONFIGURATION }} + 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 }} - name: Publish VSCE run: | pushd ./src/VSCodeExt/ diff --git a/src/CHttp/Binders/HttpBehaviorBinder.cs b/src/CHttp/Binders/HttpBehaviorBinder.cs index 2b250a6..76f1277 100644 --- a/src/CHttp/Binders/HttpBehaviorBinder.cs +++ b/src/CHttp/Binders/HttpBehaviorBinder.cs @@ -5,14 +5,14 @@ namespace CHttp.Binders; internal sealed class HttpBehaviorBinder( Option redirectBinder, - Option enableCertificateValidationBinder, + Option validateCertificateValidationBinder, Option timeout, Option cookieContainerOption, Option kerberosAuthOption, Option decompressResponse) { private readonly Option _redirectBinder = redirectBinder; - private readonly Option _enableCertificateValidationBinder = enableCertificateValidationBinder; + private readonly Option _validateCertificateValidationBinder = validateCertificateValidationBinder; private readonly Option _timeoutOption = timeout; private readonly Option _cookieContainerOption = cookieContainerOption; private readonly Option _kerberosAuthOption = kerberosAuthOption; @@ -21,7 +21,7 @@ internal sealed class HttpBehaviorBinder( internal HttpBehavior Bind(ParseResult parseResult) { var redirects = parseResult.GetValue(_redirectBinder); - var enableCertificateValidation = parseResult.GetValue(_enableCertificateValidationBinder); + var enableCertificateValidation = !parseResult.GetValue(_validateCertificateValidationBinder); var timeout = parseResult.GetValue(_timeoutOption); var cookieContainer = parseResult.GetValue(_cookieContainerOption)?.FullName ?? string.Empty; var kerberosAuth = parseResult.GetValue(_kerberosAuthOption); diff --git a/src/CHttp/CHttp.csproj b/src/CHttp/CHttp.csproj index 75d3c08..b5e5880 100644 --- a/src/CHttp/CHttp.csproj +++ b/src/CHttp/CHttp.csproj @@ -2,7 +2,7 @@ Exe - net9.0;net11.0 + net9.0;net10.0;net11.0 enable enable true diff --git a/src/CHttp/CommandFactory.cs b/src/CHttp/CommandFactory.cs index df6505a..7dea335 100644 --- a/src/CHttp/CommandFactory.cs +++ b/src/CHttp/CommandFactory.cs @@ -20,8 +20,6 @@ public static Command CreateRootCommand( IFileSystem? fileSystem = null) { CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; - // kerberos auth - // certificates // TLS // proxy @@ -122,9 +120,9 @@ public static Command CreateRootCommand( { var value = parseResult.Tokens.FirstOrDefault()?.Value; if (value == null) - return false; + return true; if (bool.TryParse(value, out var result)) - return !result; // Invert the value to match the option name + return result; // Invert the value to match the option name parseResult.AddError("Invalid value for --no-certificate-validation. Expected 'true' or 'false' or no value."); return false; }, diff --git a/src/CHttp/Data/Summary.cs b/src/CHttp/Data/Summary.cs index 3ba6497..e8951ca 100644 --- a/src/CHttp/Data/Summary.cs +++ b/src/CHttp/Data/Summary.cs @@ -37,7 +37,7 @@ internal Summary(string url, DateTime startTime, TimeSpan duration) private long _endTime; public long EndTime { - get => _endTime; + readonly get => _endTime; set { if (_endTime != default || StartTime == default) @@ -59,7 +59,7 @@ public void RequestCompleted(HttpStatusCode statusCode) HttpStatusCode = (int)statusCode; } - public override string ToString() + public readonly override string ToString() { if (!string.IsNullOrEmpty(Error)) return Error; @@ -69,19 +69,19 @@ public override string ToString() inputs.Url.CopyTo(buffer); buffer = buffer.Slice(inputs.Url.Length); buffer[0] = ' '; - buffer = buffer.Slice(1); + buffer = buffer[1..]; var responseSize = inputs.Length; if (SizeFormatter.TryFormatSize(responseSize, buffer, out var count)) { - buffer = buffer.Slice(count); + buffer = buffer[count..]; buffer[0] = ' '; - buffer = buffer.Slice(1); + buffer = buffer[1..]; } if (!inputs.Duration.TryFormat(buffer, out count, "c")) ThrowInvalidOperationException(); - buffer = buffer.Slice(count); + buffer = buffer[count..]; buffer[0] = 's'; - buffer = buffer.Slice(1); + buffer = buffer[1..]; buffer.Fill(' '); }); } diff --git a/src/CHttp/Http/HttpMessageSender.cs b/src/CHttp/Http/HttpMessageSender.cs index 5774894..9df1a15 100644 --- a/src/CHttp/Http/HttpMessageSender.cs +++ b/src/CHttp/Http/HttpMessageSender.cs @@ -1,5 +1,6 @@ using System.IO.Pipelines; using System.Net.Http.Headers; +using System.Security.Authentication; using System.Text; using CHttp.Data; using CHttp.Writers; @@ -46,7 +47,7 @@ public async Task SendRequestAsync(HttpRequestDetails requestData, CancellationT private async Task SendRequestAsync(HttpClient client, HttpRequestMessage request, CancellationToken token = default) { - Summary summary = new Summary(request.RequestUri?.ToString() ?? string.Empty); + Summary summary = new(request.RequestUri?.ToString() ?? string.Empty); HttpResponseHeaders? trailers = null; { try @@ -61,7 +62,7 @@ private async Task SendRequestAsync(HttpClient client, HttpRequestMessage reques } catch (HttpRequestException requestException) { - summary = summary with { Error = $"Request Error {requestException}", ErrorCode = ErrorType.HttpRequestException }; + summary = summary with { Error = HandleRequestException(requestException), ErrorCode = ErrorType.HttpRequestException }; } catch (HttpProtocolException protocolException) { @@ -112,4 +113,21 @@ private void SetHeaders(HttpRequestDetails requestData, HttpRequestMessage reque } } } + + private string HandleRequestException(HttpRequestException requestException) + { + if (requestException.InnerException is AuthenticationException authException) + { + if (authException.Message.StartsWith("The remote certificate is invalid")) + return $"Enable flag --no-certificate-validation true'. SSL error: {authException.Message}"; + if (authException.Message.StartsWith("Cannot determine the frame size or a corrupted frame was received")) + return $"Invalid http(s) schema: {authException.Message}"; + } + + if (requestException.InnerException is HttpIOException ioException) + { + return $"Invalid http(s) schema: {ioException.Message}"; + } + return $"Request Error {requestException}"; + } } diff --git a/src/CHttpServer/CHttpServer/Http3/Http3Connection.cs b/src/CHttpServer/CHttpServer/Http3/Http3Connection.cs index bd7c7f9..3b8c935 100644 --- a/src/CHttpServer/CHttpServer/Http3/Http3Connection.cs +++ b/src/CHttpServer/CHttpServer/Http3/Http3Connection.cs @@ -143,7 +143,7 @@ private async Task TryCloseConnection() /// Graceful shutdown (until token gets cancelled). internal async Task StopAsync(CancellationToken token) { - using var _ = token.Register(() => _abruptCts.Cancel()); // Registers for immediate abort. + using var _ = token.Register(() => { try { _abruptCts.Cancel(); } catch (ObjectDisposedException) { } }); // Registers for immediate abort. _context.ConnectionCancellation.Cancel(); // Graceful shutdown await _processingCompleted.Task; } diff --git a/src/CHttpServer/CHttpServer/ManualResetValueTaskSource.cs b/src/CHttpServer/CHttpServer/ManualResetValueTaskSource.cs index abd8199..e08d6de 100644 --- a/src/CHttpServer/CHttpServer/ManualResetValueTaskSource.cs +++ b/src/CHttpServer/CHttpServer/ManualResetValueTaskSource.cs @@ -8,7 +8,11 @@ internal class ManualResetValueTaskSource : IValueTaskSource, IValueTaskSo public bool RunContinuationsAsynchronously { get => _core.RunContinuationsAsynchronously; set => _core.RunContinuationsAsynchronously = value; } public short Version => _core.Version; public void Reset() => _core.Reset(); - public void SetException(Exception error) => _core.SetException(error); + public void SetException(Exception error) + { + if (_core.GetStatus(_core.Version) == ValueTaskSourceStatus.Pending) + _core.SetException(error); + } void IValueTaskSource.GetResult(short token) => _core.GetResult(token); public ValueTaskSourceStatus GetStatus(short token) => _core.GetStatus(token); public void OnCompleted(Action continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags) => _core.OnCompleted(continuation, state, token, flags); diff --git a/tests/CHttp.Tests/AwaiterTests.cs b/tests/CHttp.Tests/AwaiterTests.cs index c5119bd..9337767 100644 --- a/tests/CHttp.Tests/AwaiterTests.cs +++ b/tests/CHttp.Tests/AwaiterTests.cs @@ -14,6 +14,6 @@ public async Task WaitAsync_Waits50Ms() timeProvider.Advance(TimeSpan.FromMilliseconds(49)); Assert.False(waiting.IsCompleted); timeProvider.Advance(TimeSpan.FromMilliseconds(1)); - await waiting.WaitAsync(TimeSpan.FromSeconds(1)); + await waiting.WaitAsync(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); } } diff --git a/tests/CHttp.Tests/Http/CHttpFunctionalTests.cs b/tests/CHttp.Tests/Http/CHttpFunctionalTests.cs index a18b4af..7073cd7 100644 --- a/tests/CHttp.Tests/Http/CHttpFunctionalTests.cs +++ b/tests/CHttp.Tests/Http/CHttpFunctionalTests.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using System.IO.Compression; using System.Text; using CHttp.Abstractions; @@ -350,6 +349,86 @@ public async Task Diff_FunctionalTest() Assert.Contains("probability, the base session's true mean latency is", console.Text); } + [Fact] + public async Task CertificateValidationError_WithProposedSuggestion() + { + using var host = HttpServer.CreateHostBuilder(context => context.Response.WriteAsync("test"), HttpProtocols.Http2); + await host.StartAsync(TestContext.Current.CancellationToken); + var console = new TestConsolePerWrite(); + var writer = new SilentConsoleWriter(new TextBufferedProcessor(), console); + + var client = await CommandFactory.CreateRootCommand(writer) + .Parse("--method GET --uri https://localhost:5011 -v 2") + .InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); + + await writer.CompleteAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + Assert.Contains("Enable flag --no-certificate-validation true'. SSL error: The remote certificate is invalid because of errors in the certificate chain", console.Text); + } + + [Fact] + public async Task CertificateValidationError_HttpsServer_HttpRequest_H2() + { + using var host = HttpServer.CreateHostBuilder(context => context.Response.WriteAsync("test"), HttpProtocols.Http2); + await host.StartAsync(TestContext.Current.CancellationToken); + var console = new TestConsolePerWrite(); + var writer = new SilentConsoleWriter(new TextBufferedProcessor(), console); + + var client = await CommandFactory.CreateRootCommand(writer) + .Parse("--method GET --uri http://localhost:5011 -v 2") + .InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); + + await writer.CompleteAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + Assert.Contains("Invalid http(s) schema: An HTTP/2 connection could not be established", console.Text); + } + + [Fact] + public async Task CertificateValidationError_HttpsServer_HttpRequest_H3() + { + using var host = HttpServer.CreateHostBuilder(context => context.Response.WriteAsync("test"), HttpProtocols.Http3); + await host.StartAsync(TestContext.Current.CancellationToken); + var console = new TestConsolePerWrite(); + var writer = new SilentConsoleWriter(new TextBufferedProcessor(), console); + + var client = await CommandFactory.CreateRootCommand(writer) + .Parse("--method GET --uri http://localhost:5011 -v 3") + .InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); + + await writer.CompleteAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + Assert.Contains("Requesting HTTP version 3.0 with version policy RequestVersionExact while unable to establish HTTP/3 connection.", console.Text); + } + + [Fact] + public async Task CertificateValidationError_HttpsServer_HttpRequest_H1() + { + using var host = HttpServer.CreateHostBuilder(context => context.Response.WriteAsync("test"), HttpProtocols.Http1); + await host.StartAsync(TestContext.Current.CancellationToken); + var console = new TestConsolePerWrite(); + var writer = new SilentConsoleWriter(new TextBufferedProcessor(), console); + + var client = await CommandFactory.CreateRootCommand(writer) + .Parse("--method GET --uri http://localhost:5011 -v 1.1") + .InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); + + await writer.CompleteAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + Assert.Contains("Invalid http(s) schema: The response ended prematurely.", console.Text); + } + + [Fact] + public async Task CertificateValidationError_HttpServer_HttpsRequest() + { + using var host = HttpServer.CreateHostBuilder(context => context.Response.WriteAsync("test"), HttpProtocols.Http1, withHttps: false); + await host.StartAsync(TestContext.Current.CancellationToken); + var console = new TestConsolePerWrite(); + var writer = new SilentConsoleWriter(new TextBufferedProcessor(), console); + + var client = await CommandFactory.CreateRootCommand(writer) + .Parse("--method GET --uri https://localhost:5011 -v 1.1") + .InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); + + await writer.CompleteAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + Assert.Contains("Invalid http(s) schema: Cannot determine the frame size or a corrupted frame was received.", console.Text); + } + private class Request { public string? Data { get; set; } diff --git a/tests/CHttp.Tests/HttpServer.cs b/tests/CHttp.Tests/HttpServer.cs index 325a227..e1f8ce8 100644 --- a/tests/CHttp.Tests/HttpServer.cs +++ b/tests/CHttp.Tests/HttpServer.cs @@ -9,32 +9,34 @@ namespace CHttp.Tests; public static class HttpServer { - public static WebApplication CreateHostBuilder(RequestDelegate? requestDelegate = null, - HttpProtocols? protocol = null, - Action? configureKestrel = null, - Action? configureServices = null, - Action? configureApp = null, - int port = 5011, - string path = "/") - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseKestrel(kestrel => - { - kestrel.ListenAnyIP(port, options => - { - options.UseHttps(X509CertificateLoader.LoadPkcs12FromFile("testCert.pfx", "testPassword")); - options.Protocols = protocol ?? HttpProtocols.Http3; - }); - configureKestrel?.Invoke(kestrel); - }); + public static WebApplication CreateHostBuilder(RequestDelegate? requestDelegate = null, + HttpProtocols? protocol = null, + Action? configureKestrel = null, + Action? configureServices = null, + Action? configureApp = null, + int port = 5011, + string path = "/", + bool withHttps = true) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseKestrel(kestrel => + { + kestrel.ListenAnyIP(port, options => + { + if (withHttps) + options.UseHttps(X509CertificateLoader.LoadPkcs12FromFile("testCert.pfx", "testPassword")); + options.Protocols = protocol ?? HttpProtocols.Http3; + }); + configureKestrel?.Invoke(kestrel); + }); - configureServices?.Invoke(builder.Services); - var app = builder.Build(); + configureServices?.Invoke(builder.Services); + var app = builder.Build(); - if (requestDelegate != null) - app.Map(path, requestDelegate); - else if (configureApp != null) - configureApp.Invoke(app); - return app; - } + if (requestDelegate != null) + app.Map(path, requestDelegate); + else + configureApp?.Invoke(app); + return app; + } } \ No newline at end of file diff --git a/tests/CHttp.Tests/Writers/StreamBufferedProcessorTests.cs b/tests/CHttp.Tests/Writers/StreamBufferedProcessorTests.cs index c221c9a..14b5b4e 100644 --- a/tests/CHttp.Tests/Writers/StreamBufferedProcessorTests.cs +++ b/tests/CHttp.Tests/Writers/StreamBufferedProcessorTests.cs @@ -17,7 +17,7 @@ public async Task Run_CopiesDataTo_OutputStream() var input = Enumerable.Range(0, 100).Select(x => (byte)x).ToArray(); var sut = new StreamBufferedProcessor(outputStream); var sutRun = sut.RunAsync(_ => Task.CompletedTask); - await sut.Pipe.WriteAsync(input); + await sut.Pipe.WriteAsync(input, TestContext.Current.CancellationToken); sut.Pipe.Complete(); await sutRun; Assert.True(input.SequenceEqual(outputStream.ToArray())); @@ -31,7 +31,7 @@ public async Task EmptyArrayRun_CopiesDataTo_OutputStream() var input = new byte[0]; var sut = new StreamBufferedProcessor(outputStream); var sutRun = sut.RunAsync(_ => Task.CompletedTask); - await sut.Pipe.WriteAsync(input); + await sut.Pipe.WriteAsync(input, TestContext.Current.CancellationToken); sut.Pipe.Complete(); await sutRun; Assert.True(input.SequenceEqual(outputStream.ToArray())); @@ -44,7 +44,7 @@ public async Task LargeArrayRun_CopiesDataTo_OutputStream() var input = Enumerable.Range(0, 100000).Select(x => (byte)x).ToArray(); var sut = new StreamBufferedProcessor(outputStream); var sutRun = sut.RunAsync(_ => Task.CompletedTask); - await sut.Pipe.WriteAsync(input); + await sut.Pipe.WriteAsync(input, TestContext.Current.CancellationToken); sut.Pipe.Complete(); await sutRun; Assert.True(input.SequenceEqual(outputStream.ToArray())); @@ -63,7 +63,7 @@ public async Task MultiWrittenArrayRun_CopiesDataTo_OutputStream(int segmentLeng while (remainderToWrite.Length > 0) { - await sut.Pipe.WriteAsync(remainderToWrite.Slice(0, segmentLength)); + await sut.Pipe.WriteAsync(remainderToWrite.Slice(0, segmentLength), TestContext.Current.CancellationToken); remainderToWrite = remainderToWrite.Slice(segmentLength); } @@ -79,7 +79,7 @@ public async Task WhenCancelled_Run_Completes() var sut = new StreamBufferedProcessor(outputStream); var sutRun = sut.RunAsync(_ => Task.CompletedTask); sut.Cancel(); - await sutRun.WaitAsync(TimeSpan.FromSeconds(1)); + await sutRun.WaitAsync(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); Assert.Equal(0, outputStream.Length); Assert.Equal(0, sut.Position); } diff --git a/tests/CHttpExecutor.Tests/IntegrationTests.cs b/tests/CHttpExecutor.Tests/IntegrationTests.cs index bf126b2..31805c8 100644 --- a/tests/CHttpExecutor.Tests/IntegrationTests.cs +++ b/tests/CHttpExecutor.Tests/IntegrationTests.cs @@ -360,7 +360,7 @@ public async Task FailingAssert_ReturnsErrors() { using var host = HttpServer.CreateHostBuilder(async context => { - await Task.Delay(1); + await Task.Delay(2); await context.Response.WriteAsync("test"); }, HttpProtocols.Http2, port: Port); await host.StartAsync(TestContext.Current.CancellationToken); diff --git a/tests/CHttpExecutor.Tests/StringLinesContentTests.cs b/tests/CHttpExecutor.Tests/StringLinesContentTests.cs index 5383002..26fcf83 100644 --- a/tests/CHttpExecutor.Tests/StringLinesContentTests.cs +++ b/tests/CHttpExecutor.Tests/StringLinesContentTests.cs @@ -10,7 +10,7 @@ public async Task SingleWrite() List input = ["test"]; var sut = new StringLinesContent(input); using var ms = new MemoryStream(); - await sut.CopyToAsync(ms); + await sut.CopyToAsync(ms, TestContext.Current.CancellationToken); ms.Seek(0, SeekOrigin.Begin); Assert.Equal(Encoding.UTF8.GetBytes("test"), ms.ToArray()); } @@ -21,7 +21,7 @@ public async Task TwoWrites() List input = ["test", "test"]; var sut = new StringLinesContent(input); using var ms = new MemoryStream(); - await sut.CopyToAsync(ms); + await sut.CopyToAsync(ms, TestContext.Current.CancellationToken); ms.Seek(0, SeekOrigin.Begin); Assert.Equal(Encoding.UTF8.GetBytes("testtest"), ms.ToArray()); } @@ -32,7 +32,7 @@ public async Task LargeContent() List input = ["test", new string('a', 8 * 1024), "test2"]; var sut = new StringLinesContent(input); using var ms = new MemoryStream(); - await sut.CopyToAsync(ms); + await sut.CopyToAsync(ms, TestContext.Current.CancellationToken); ms.Seek(0, SeekOrigin.Begin); StringBuilder sb = new(); sb.Append(input[0]); diff --git a/tests/CHttpServer.Tests/TestServer.cs b/tests/CHttpServer.Tests/TestServer.cs index cf2526a..b6815ec 100644 --- a/tests/CHttpServer.Tests/TestServer.cs +++ b/tests/CHttpServer.Tests/TestServer.cs @@ -1,10 +1,8 @@ -using System.Net; -using System.Security.Cryptography.X509Certificates; +using System.Security.Cryptography.X509Certificates; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace CHttpServer.Tests; @@ -18,6 +16,7 @@ public Task RunAsync(int port = 7222, bool usePriority = false, bool useHttp3 = if (_app != null) return Task.CompletedTask; var builder = WebApplication.CreateBuilder(); + builder.Logging.SetMinimumLevel(LogLevel.Error); builder.UseCHttpServer(o => { o.Port = port;