From 147978b656379fcb0e51ff7ae0a644b290c66e4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20N=C3=A4geli?= Date: Wed, 5 Aug 2026 20:19:23 +0200 Subject: [PATCH 1/3] Add code generator test coverage --- .../GenHTTP.Testing.Acceptance.csproj | 6 + .../Generators/MemoryViewGeneratorTests.cs | 139 ++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 Testing/Acceptance/Generators/MemoryViewGeneratorTests.cs diff --git a/Testing/Acceptance/GenHTTP.Testing.Acceptance.csproj b/Testing/Acceptance/GenHTTP.Testing.Acceptance.csproj index 9a603e82..c0a97d63 100644 --- a/Testing/Acceptance/GenHTTP.Testing.Acceptance.csproj +++ b/Testing/Acceptance/GenHTTP.Testing.Acceptance.csproj @@ -44,6 +44,8 @@ + + @@ -78,6 +80,10 @@ + + + diff --git a/Testing/Acceptance/Generators/MemoryViewGeneratorTests.cs b/Testing/Acceptance/Generators/MemoryViewGeneratorTests.cs new file mode 100644 index 00000000..e6a6126b --- /dev/null +++ b/Testing/Acceptance/Generators/MemoryViewGeneratorTests.cs @@ -0,0 +1,139 @@ +using System.Collections.Immutable; +using System.Text.RegularExpressions; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +using GenHTTP.Generators.MemoryView; + +namespace GenHTTP.Testing.Acceptance.Generators; + +/// +/// Unit tests driving the memory view source generator directly (no server, no I/O) so that +/// its code is executed in-process and picked up by coverage tooling, instead of only ever +/// running inside the compiler process during a build. +/// +[TestClass] +public sealed class MemoryViewGeneratorTests +{ + + #region Supporting infrastructure + + private static (ImmutableArray Sources, ImmutableArray Diagnostics) Generate(string source) + { + var references = AppDomain.CurrentDomain + .GetAssemblies() + .Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location)) + .Select(a => (MetadataReference)MetadataReference.CreateFromFile(a.Location)); + + var compilation = CSharpCompilation.Create( + assemblyName: "MemoryViewGeneratorTests.Generated", + syntaxTrees: [CSharpSyntaxTree.ParseText(source)], + references: references, + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + + GeneratorDriver driver = CSharpGeneratorDriver.Create(new MemoryViewGenerator()); + + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out _); + + var result = driver.GetRunResult().Results.Single(); + + return (result.GeneratedSources, outputCompilation.GetDiagnostics()); + } + + private static void AssertNoErrors(ImmutableArray diagnostics) + { + var errors = diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error).ToList(); + + Assert.IsTrue(errors.Count == 0, string.Join(Environment.NewLine, errors)); + } + + #endregion + + #region Tests + + [TestMethod] + public void TestStructInNamespaceIsGenerated() + { + const string source = """ + namespace MyApp; + + [GenHTTP.Api.MemoryViewAttribute] + public readonly partial struct MyView { } + """; + + var (sources, diagnostics) = Generate(source); + + Assert.AreEqual(1, sources.Length); + Assert.AreEqual("MyApp.MyView.MemoryView.g.cs", sources[0].HintName); + + var text = sources[0].SourceText.ToString(); + + StringAssert.Contains(text, "namespace MyApp;"); + StringAssert.Contains(text, "public readonly partial struct MyView"); + StringAssert.Contains(text, "public bool Equals(MyView other)"); + + AssertNoErrors(diagnostics); + } + + [TestMethod] + public void TestStructInGlobalNamespaceIsGenerated() + { + const string source = """ + [GenHTTP.Api.MemoryViewAttribute] + public readonly partial struct MyGlobalView { } + """; + + var (sources, diagnostics) = Generate(source); + + Assert.AreEqual(1, sources.Length); + Assert.AreEqual("MyGlobalView.MemoryView.g.cs", sources[0].HintName); + + StringAssert.DoesNotMatch(sources[0].SourceText.ToString(), new Regex("^namespace ", RegexOptions.Multiline)); + + AssertNoErrors(diagnostics); + } + + [TestMethod] + public void TestStructWithoutAttributeIsIgnored() + { + const string source = """ + namespace MyApp; + + public readonly partial struct MyView { } + """; + + var (sources, _) = Generate(source); + + Assert.AreEqual(0, sources.Length); + } + + [TestMethod] + public void TestMultipleStructsProduceIndependentSources() + { + const string source = """ + namespace MyApp; + + [GenHTTP.Api.MemoryViewAttribute] + public readonly partial struct First { } + + [GenHTTP.Api.MemoryViewAttribute] + public readonly partial struct Second { } + """; + + var (sources, diagnostics) = Generate(source); + + Assert.AreEqual(2, sources.Length); + + CollectionAssert.AreEquivalent( + new[] { "MyApp.First.MemoryView.g.cs", "MyApp.Second.MemoryView.g.cs" }, + sources.Select(s => s.HintName).ToList() + ); + + AssertNoErrors(diagnostics); + } + + #endregion + +} From 431a1b669efbada5130bc6f9b18a988b0588a963 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20N=C3=A4geli?= Date: Wed, 5 Aug 2026 21:26:33 +0200 Subject: [PATCH 2/3] Test memory views, exclude ioxide TLS --- .github/workflows/build.yml | 2 +- Modules/Websockets/Utils/PooledMemoryOwner.cs | 33 ------------------- .../Generators/MemoryViewGeneratorTests.cs | 5 --- 3 files changed, 1 insertion(+), 39 deletions(-) delete mode 100644 Modules/Websockets/Utils/PooledMemoryOwner.cs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 529fb0cb..0111fda6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -52,7 +52,7 @@ jobs: - name: Begin scan if: env.SONAR_TOKEN != null && env.SONAR_TOKEN != '' - run: dotnet sonarscanner begin /k:"GenHTTP" /d:sonar.token="$SONAR_TOKEN" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" /d:sonar.exclusions="**/bin/**/*,**/obj/**/*,**/*.css,**/*.js,**/*.html" /o:"kaliumhexacyanoferrat" /k:"GenHTTP" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.branch.name="${GITHUB_REF##*/}" /d:sonar.dotnet.excludeTestProjects=true + run: dotnet sonarscanner begin /k:"GenHTTP" /d:sonar.token="$SONAR_TOKEN" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" /d:sonar.exclusions="**/bin/**/*,**/obj/**/*,**/*.css,**/*.js,**/*.html" /d:sonar.coverage.exclusions="**/Engine/Ioxide/Tls/TlsDuplexPipe.cs" /o:"kaliumhexacyanoferrat" /k:"GenHTTP" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.branch.name="${GITHUB_REF##*/}" /d:sonar.dotnet.excludeTestProjects=true - name: Build project run: dotnet build GenHTTP.slnx -c Release diff --git a/Modules/Websockets/Utils/PooledMemoryOwner.cs b/Modules/Websockets/Utils/PooledMemoryOwner.cs deleted file mode 100644 index 4b799e91..00000000 --- a/Modules/Websockets/Utils/PooledMemoryOwner.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Buffers; - -namespace GenHTTP.Modules.Websockets.Utils; - -public sealed class PooledMemoryOwner : IMemoryOwner -{ - private readonly ArrayPool _pool; - - private byte[]? _buffer; - - public PooledMemoryOwner(byte[] buffer, int length, ArrayPool pool) - { - _buffer = buffer; - - Memory = new Memory(_buffer, 0, length); - - _pool = pool; - } - - public Memory Memory { get; } - - public void Dispose() - { - if (_buffer == null) - { - return; - } - - _pool.Return(_buffer); - - _buffer = null; - } -} \ No newline at end of file diff --git a/Testing/Acceptance/Generators/MemoryViewGeneratorTests.cs b/Testing/Acceptance/Generators/MemoryViewGeneratorTests.cs index e6a6126b..2ce4eaab 100644 --- a/Testing/Acceptance/Generators/MemoryViewGeneratorTests.cs +++ b/Testing/Acceptance/Generators/MemoryViewGeneratorTests.cs @@ -8,11 +8,6 @@ namespace GenHTTP.Testing.Acceptance.Generators; -/// -/// Unit tests driving the memory view source generator directly (no server, no I/O) so that -/// its code is executed in-process and picked up by coverage tooling, instead of only ever -/// running inside the compiler process during a build. -/// [TestClass] public sealed class MemoryViewGeneratorTests { From b38a7b282d5369d0bc7ca1f1aa49eebf5cdb46ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20N=C3=A4geli?= Date: Thu, 6 Aug 2026 10:49:31 +0200 Subject: [PATCH 3/3] Add more coverage --- Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj | 4 + .../Engine/Body/ChunkedBodyStreamTests.cs | 67 ++++++++++ .../Acceptance/Engine/Body/DrainBodyTests.cs | 27 ++++ Testing/Acceptance/Engine/HostTests.cs | 19 +++ .../Engine/Ioxide/PipeWriterStreamTests.cs | 68 ++++++++++ .../Engine/Kestrel/ProtocolTests.cs | 31 +++++ .../Acceptance/Engine/RequestPropertyTest.cs | 56 ++++++++ .../BearerAuthenticationTests.cs | 101 ++++++++++++++- .../Modules/IO/WriterStreamAdapterTests.cs | 40 ++++++ .../Modules/IoxideFiles/IoxideFilesTests.cs | 52 ++++++++ .../Modules/ReverseProxy/ReverseProxyTests.cs | 87 +++++++++++++ .../RawWebsocketConnectionTests.cs | 81 ++++++++++++ .../WebsocketProxy/WebsocketTunnelTests.cs | 35 +++++ .../Imperative/FrameEdgeCaseTests.cs | 121 ++++++++++++++++++ .../Integration/Pipelining/PipeliningTests.cs | 48 +++++++ .../RawClient/RawWebsocketClient.cs | 61 +++++++++ 16 files changed, 897 insertions(+), 1 deletion(-) create mode 100644 Testing/Acceptance/Engine/Ioxide/PipeWriterStreamTests.cs create mode 100644 Testing/Acceptance/Modules/ReverseProxy/WebsocketProxy/RawWebsocketConnectionTests.cs create mode 100644 Testing/Acceptance/Modules/Websockets/Integration/Imperative/FrameEdgeCaseTests.cs diff --git a/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj b/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj index 2ee39b7c..59680062 100644 --- a/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj +++ b/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj @@ -17,4 +17,8 @@ + + + + diff --git a/Testing/Acceptance/Engine/Body/ChunkedBodyStreamTests.cs b/Testing/Acceptance/Engine/Body/ChunkedBodyStreamTests.cs index 9c69a4b8..5384a79f 100644 --- a/Testing/Acceptance/Engine/Body/ChunkedBodyStreamTests.cs +++ b/Testing/Acceptance/Engine/Body/ChunkedBodyStreamTests.cs @@ -48,6 +48,73 @@ public void TestBasics() Assert.ThrowsExactly(() => stream.SetLength(0)); Assert.ThrowsExactly(() => stream.Write([], 0, 0)); Assert.ThrowsExactly(() => _ = stream.Length); + + Assert.ThrowsExactly(() => _ = stream.Position); + Assert.ThrowsExactly(() => stream.Position = 0); + } + + [TestMethod] + public async Task TestReadAsyncByteArrayOverload() + { + var stream = await CreateAsync("5\r\nHello\r\n0\r\n\r\n"); + + var buffer = new byte[5]; + var read = await stream.ReadAsync(buffer, 0, buffer.Length, CancellationToken.None); + + Assert.AreEqual(5, read); + Assert.AreEqual("Hello", Encoding.ASCII.GetString(buffer)); + } + + [TestMethod] + public async Task TestReadIntoEmptyBufferReturnsZero() + { + var stream = await CreateAsync("5\r\nHello\r\n0\r\n\r\n"); + + Assert.AreEqual(0, await stream.ReadAsync(Memory.Empty)); + } + + [TestMethod] + public async Task TestReadOnEmptyCompletedPipeReturnsZero() + { + var stream = await CreateAsync(""); + + Assert.AreEqual(0, await stream.ReadAsync(new byte[16])); + } + + [TestMethod] + public async Task TestTruncatedChunkThrows() + { + var stream = await CreateAsync("5\r\nHel"); + +#pragma warning disable CA2022 // expected to throw before any byte count is returned + await Assert.ThrowsExactlyAsync(async () => await stream.ReadAsync(new byte[16])); +#pragma warning restore CA2022 + } + + [TestMethod] + public async Task TestDrainOnEmptyCompletedPipe() + { + var stream = await CreateAsync(""); + + await stream.DrainAsync(); + } + + [TestMethod] + public async Task TestDrainOnTruncatedChunkReturnsWithoutThrowing() + { + var stream = await CreateAsync("5\r\nHel"); + + await stream.DrainAsync(); + } + + private static async Task CreateAsync(string chunkedBody) + { + var pipe = new Pipe(); + + await pipe.Writer.WriteAsync(Encoding.ASCII.GetBytes(chunkedBody)); + await pipe.Writer.CompleteAsync(); + + return new ChunkedBodyStream(pipe.Reader); } } diff --git a/Testing/Acceptance/Engine/Body/DrainBodyTests.cs b/Testing/Acceptance/Engine/Body/DrainBodyTests.cs index e5dc6b5b..89577884 100644 --- a/Testing/Acceptance/Engine/Body/DrainBodyTests.cs +++ b/Testing/Acceptance/Engine/Body/DrainBodyTests.cs @@ -146,6 +146,33 @@ public async Task TestPartialRead(TestEngine engine) Assert.AreEqual(3, handler.RequestCount); } + [TestMethod] + [MultiEngineTest] + public async Task TestPartialReadChunked(TestEngine engine) + { + var handler = new PartialReadHandler(bytesToRead: 4); + + await using var runner = await TestHost.RunAsync(handler.Wrap(), engine: engine); + + using var client = TestHost.GetClient(); + + for (var i = 0; i < 3; i++) + { + var request = new HttpRequestMessage(HttpMethod.Post, runner.GetUrl()) + { + Content = new StreamContent(new MemoryStream("Hello, body!"u8.ToArray())) + }; + + request.Headers.TransferEncodingChunked = true; + + using var response = await client.SendAsync(request); + + await response.AssertStatusAsync(HttpStatusCode.OK); + } + + Assert.AreEqual(3, handler.RequestCount); + } + #endregion #region Supporting types diff --git a/Testing/Acceptance/Engine/HostTests.cs b/Testing/Acceptance/Engine/HostTests.cs index 7b275d5f..55d9a16a 100644 --- a/Testing/Acceptance/Engine/HostTests.cs +++ b/Testing/Acceptance/Engine/HostTests.cs @@ -1,4 +1,5 @@ using System.Net; +using GenHTTP.Engine.Internal; using GenHTTP.Modules.Layouting; namespace GenHTTP.Testing.Acceptance.Engine; @@ -7,6 +8,24 @@ namespace GenHTTP.Testing.Acceptance.Engine; public sealed class HostTests { + [TestMethod] + public void TestPortZeroThrows() + { + var host = Host.Create(); + + Assert.ThrowsExactly(() => host.Port(0)); + } + + [TestMethod] + public async Task TestRunAsyncReturnsErrorCodeWithoutHandler() + { + var host = Host.Create(); + + var exitCode = await host.RunAsync(); + + Assert.AreEqual(-1, exitCode); + } + [TestMethod] [MultiEngineTest] public async Task TestStart(TestEngine engine) diff --git a/Testing/Acceptance/Engine/Ioxide/PipeWriterStreamTests.cs b/Testing/Acceptance/Engine/Ioxide/PipeWriterStreamTests.cs new file mode 100644 index 00000000..222279c0 --- /dev/null +++ b/Testing/Acceptance/Engine/Ioxide/PipeWriterStreamTests.cs @@ -0,0 +1,68 @@ +#if NET11_0_OR_GREATER + +using System.Buffers; +using System.IO.Pipelines; +using System.Text; + +using GenHTTP.Engine.Ioxide.Protocol; + +namespace GenHTTP.Testing.Acceptance.Engine.Ioxide; + +[TestClass] +public sealed class PipeWriterStreamTests +{ + + [TestMethod] + public void TestBasics() + { + var pipe = new Pipe(); + + var stream = new PipeWriterStream(new ArrayBufferWriter(), pipe.Writer); + + Assert.IsFalse(stream.CanRead); + Assert.IsFalse(stream.CanSeek); + Assert.IsTrue(stream.CanWrite); + + Assert.ThrowsExactly(() => _ = stream.Length); + Assert.ThrowsExactly(() => _ = stream.Position); + Assert.ThrowsExactly(() => stream.Position = 0); + + Assert.ThrowsExactly(() => stream.Read([], 0, 1)); + Assert.ThrowsExactly(() => stream.Seek(0, SeekOrigin.Begin)); + Assert.ThrowsExactly(() => stream.SetLength(0)); + + stream.Flush(); + } + + [TestMethod] + public void TestWriteByte() + { + var writer = new ArrayBufferWriter(); + + var stream = new PipeWriterStream(writer, new Pipe().Writer); + + stream.WriteByte((byte)'H'); + stream.WriteByte((byte)'i'); + + Assert.AreEqual("Hi", Encoding.ASCII.GetString(writer.WrittenSpan)); + } + + [TestMethod] + public async Task TestFlushAsyncDrainsUnderlyingPipe() + { + var pipe = new Pipe(); + + var stream = new PipeWriterStream(pipe.Writer, pipe.Writer); + + await stream.WriteAsync("Hello"u8.ToArray()); + + await stream.FlushAsync(); + + var read = await pipe.Reader.ReadAsync(); + + Assert.AreEqual("Hello", Encoding.ASCII.GetString(read.Buffer.ToArray())); + } + +} + +#endif diff --git a/Testing/Acceptance/Engine/Kestrel/ProtocolTests.cs b/Testing/Acceptance/Engine/Kestrel/ProtocolTests.cs index e15bdb6a..b370e507 100644 --- a/Testing/Acceptance/Engine/Kestrel/ProtocolTests.cs +++ b/Testing/Acceptance/Engine/Kestrel/ProtocolTests.cs @@ -40,6 +40,37 @@ public async Task TestHttp2And3() } } + [TestMethod] + public async Task TestHttpsOnSpecificAddress() + { + if (!Engines.KestrelEnabled()) return; + + var logic = Inline.Create().Get(() => "Hello"); + + var certificate = await Utilities.Security.GetCertificateAsync(); + + var runner = new TestHost(logic.Build(), engine: TestEngine.Kestrel); + + var port = TestHost.NextPort(); + + runner.Host.Bind(IPAddress.Loopback, (ushort)port, certificate); + + await runner.StartAsync(); + + try + { + using var client = TestHost.GetClient(ignoreSecurityErrors: true); + + using var response = await client.GetAsync($"https://localhost:{port}"); + + await response.AssertStatusAsync(HttpStatusCode.OK); + } + finally + { + await runner.DisposeAsync(); + } + } + #region Helpers private static HttpClient GetClient() diff --git a/Testing/Acceptance/Engine/RequestPropertyTest.cs b/Testing/Acceptance/Engine/RequestPropertyTest.cs index d29e3866..be28e674 100644 --- a/Testing/Acceptance/Engine/RequestPropertyTest.cs +++ b/Testing/Acceptance/Engine/RequestPropertyTest.cs @@ -1,8 +1,12 @@ using System.Net; +using GenHTTP.Api.Infrastructure; using GenHTTP.Api.Protocol; +using GenHTTP.Engine.Shared.Types; using GenHTTP.Modules.Functional; +using NSubstitute; + namespace GenHTTP.Testing.Acceptance.Engine; [TestClass] @@ -35,4 +39,56 @@ public async Task TestRequestProperties(TestEngine engine) await response.AssertStatusAsync(HttpStatusCode.OK); } + [TestMethod] + [MultiEngineTest] + public async Task TestGetBodyTwiceThrows(TestEngine engine) + { + var app = Inline.Create().Get((IRequest request) => + { + request.GetBody(); + + Assert.ThrowsExactly(() => request.GetBody()); + + return true; + }); + + await using var host = await TestHost.RunAsync(app, engine: engine); + + using var response = await host.GetResponseAsync(); + + await response.AssertStatusAsync(HttpStatusCode.OK); + } + + [TestMethod] + [MultiEngineTest] + public async Task TestHeaderAccessibleAfterBodyLoaded(TestEngine engine) + { + var app = Inline.Create().Get((IRequest request) => + { + request.GetBody(); + + return request.Header.Target.AsString(decode: false); + }); + + await using var host = await TestHost.RunAsync(app, engine: engine); + + using var response = await host.GetResponseAsync(); + + await response.AssertStatusAsync(HttpStatusCode.OK); + } + + [TestMethod] + public void TestApplyWithoutEndpoint() + { + var request = new Request(); + + var server = Substitute.For(); + + request.Apply(server); + + Assert.AreSame(server, request.Server); + Assert.IsNotNull(request.Header); + Assert.IsNotNull(request.Properties); + } + } diff --git a/Testing/Acceptance/Modules/Authentication/BearerAuthenticationTests.cs b/Testing/Acceptance/Modules/Authentication/BearerAuthenticationTests.cs index d39794b0..6792f5b2 100644 --- a/Testing/Acceptance/Modules/Authentication/BearerAuthenticationTests.cs +++ b/Testing/Acceptance/Modules/Authentication/BearerAuthenticationTests.cs @@ -1,11 +1,17 @@ -using System.Net; +using System.IdentityModel.Tokens.Jwt; +using System.Net; using System.Net.Http.Headers; +using System.Security.Cryptography; + +using Microsoft.IdentityModel.Tokens; + using GenHTTP.Api.Content; using GenHTTP.Api.Content.Authentication; using GenHTTP.Api.Protocol; using GenHTTP.Modules.Authentication; using GenHTTP.Modules.Authentication.Bearer; using GenHTTP.Modules.Functional; +using GenHTTP.Modules.Layouting; namespace GenHTTP.Testing.Acceptance.Modules.Authentication; @@ -108,6 +114,99 @@ public async Task TestMalformedToken(TestEngine engine) await response.AssertStatusAsync(HttpStatusCode.BadRequest); } + [TestMethod] + [MultiEngineTest] + public async Task TestIssuerFetchesRealSigningKeys(TestEngine engine) + { + using var rsa = RSA.Create(2048); + + await using var issuerHost = await CreateIssuerAsync(engine, rsa); + + var issuer = issuerHost.GetUrl(); + + var auth = BearerAuthentication.Create().Issuer(issuer).AllowExpired(); + + var token = CreateToken(issuer, rsa); + + using var response = await Execute(auth, engine, token); + + await response.AssertStatusAsync(HttpStatusCode.OK); + } + + [TestMethod] + [MultiEngineTest] + public async Task TestIssuerRejectsTokenSignedWithUnknownKey(TestEngine engine) + { + using var rsa = RSA.Create(2048); + using var otherRsa = RSA.Create(2048); + + await using var issuerHost = await CreateIssuerAsync(engine, rsa); + + var issuer = issuerHost.GetUrl(); + + var auth = BearerAuthentication.Create().Issuer(issuer); + + var token = CreateToken(issuer, otherRsa); + + using var response = await Execute(auth, engine, token); + + await response.AssertStatusAsync(HttpStatusCode.Unauthorized); + } + + [TestMethod] + [MultiEngineTest] + public async Task TestUnreachableIssuerConfigYieldsInternalServerError(TestEngine engine) + { + // no ".well-known/openid-configuration" route configured -> the issuer 404s + await using var issuerHost = await TestHost.RunAsync(Layout.Create(), engine: engine); + + var issuer = issuerHost.GetUrl(); + + var auth = BearerAuthentication.Create().Issuer(issuer).AllowExpired(); + + using var rsa = RSA.Create(2048); + + var token = CreateToken(issuer, rsa); + + using var response = await Execute(auth, engine, token); + + await response.AssertStatusAsync(HttpStatusCode.InternalServerError); + } + + private static async Task CreateIssuerAsync(TestEngine engine, RSA rsa) + { + var securityKey = new RsaSecurityKey(rsa) { KeyId = "test-key" }; + var jwk = JsonWebKeyConverter.ConvertFromRSASecurityKey(securityKey); + + var jwksJson = $$"""{"keys":[{"kty":"RSA","use":"sig","kid":"{{jwk.Kid}}","n":"{{jwk.N}}","e":"{{jwk.E}}","alg":"RS256"}]}"""; + + // the handler references the host's own port (for jwks_uri), so it's built after the + // port is known but before the not-yet-started host is actually started + var issuerHost = new TestHost(Layout.Create().Build(), engine: engine); + + var configJson = $$"""{"jwks_uri":"{{issuerHost.GetUrl("/jwks")}}"}"""; + + issuerHost.Host.Handler(Layout.Create() + .Add(".well-known", Layout.Create() + .Add("openid-configuration", Inline.Create().Get(() => configJson))) + .Add("jwks", Inline.Create().Get(() => jwksJson))); + + await issuerHost.StartAsync(); + + return issuerHost; + } + + private static string CreateToken(string issuer, RSA rsa) + { + var handler = new JwtSecurityTokenHandler(); + + var credentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256); + + var token = new JwtSecurityToken(issuer: issuer, claims: [], signingCredentials: credentials); + + return handler.WriteToken(token); + } + private static async Task Execute(BearerAuthenticationConcernBuilder builder, TestEngine engine, string? token = null) { var handler = Inline.Create() diff --git a/Testing/Acceptance/Modules/IO/WriterStreamAdapterTests.cs b/Testing/Acceptance/Modules/IO/WriterStreamAdapterTests.cs index c1c67d0c..a143962f 100644 --- a/Testing/Acceptance/Modules/IO/WriterStreamAdapterTests.cs +++ b/Testing/Acceptance/Modules/IO/WriterStreamAdapterTests.cs @@ -37,6 +37,46 @@ public void TestBasics() Assert.ThrowsExactly(() => stream.Seek(0, SeekOrigin.Begin)); Assert.ThrowsExactly(() => stream.SetLength(0)); Assert.ThrowsExactly(() => _ = stream.Length); + + Assert.ThrowsExactly(() => _ = stream.Position); + Assert.ThrowsExactly(() => stream.Position = 0); + + Assert.ThrowsExactly(() => stream.Read(Span.Empty)); + + stream.Flush(); + } + + [TestMethod] + public async Task TestReadAsyncNotSupported() + { + using var stream = new WriterStreamAdapter(new ArrayBufferWriter()); + +#pragma warning disable CA2022 // both calls are expected to throw before any byte count is returned + await Assert.ThrowsExactlyAsync(async () => await stream.ReadAsync(Memory.Empty)); + await Assert.ThrowsExactlyAsync(async () => await stream.ReadAsync([], 0, 1, CancellationToken.None)); +#pragma warning restore CA2022 + } + + [TestMethod] + public async Task TestWriteAsyncByteArrayOverload() + { + var writer = new ArrayBufferWriter(); + + using var stream = new WriterStreamAdapter(writer); + + await stream.WriteAsync("Hi"u8.ToArray(), 0, 2, CancellationToken.None); + + Assert.AreEqual("Hi", Encoding.ASCII.GetString(writer.WrittenSpan)); + + await Assert.ThrowsExactlyAsync(async () => await stream.WriteAsync([], 0, 0, new CancellationToken(true))); + } + + [TestMethod] + public async Task TestFlushAsyncCancelled() + { + using var stream = new WriterStreamAdapter(new ArrayBufferWriter()); + + await Assert.ThrowsExactlyAsync(async () => await stream.FlushAsync(new CancellationToken(true))); } } diff --git a/Testing/Acceptance/Modules/IoxideFiles/IoxideFilesTests.cs b/Testing/Acceptance/Modules/IoxideFiles/IoxideFilesTests.cs index 87c3c036..20a41a66 100644 --- a/Testing/Acceptance/Modules/IoxideFiles/IoxideFilesTests.cs +++ b/Testing/Acceptance/Modules/IoxideFiles/IoxideFilesTests.cs @@ -185,6 +185,58 @@ await RunAsync(async host => }); } + [TestMethod] + public async Task TestLargeFileIsServedFromDisk() + { + if (!Engines.IoxideEnabled()) return; + + var dir = Directory.CreateTempSubdirectory(); + + // AssetCache.DefaultMaxCachedFileBytes is 256 KB - go over it so the asset is read off the ring + // instead of being served from the baked in-memory response. + var content = string.Concat(Enumerable.Repeat("0123456789", 30_000)); + + await File.WriteAllTextAsync(Path.Combine(dir.FullName, "large.txt"), content); + + var handler = IoxideFilesModule.From(dir.FullName); + + await using var host = await TestHost.RunAsync(handler, engine: TestEngine.Ioxide); + + for (var i = 0; i < 3; i++) + { + using var response = await host.GetResponseAsync("/large.txt"); + + await response.AssertStatusAsync(HttpStatusCode.OK); + + Assert.AreEqual(content, await response.GetContentAsync()); + } + } + + [TestMethod] + public async Task TestChangedFileServesUpdatedContent() + { + if (!Engines.IoxideEnabled()) return; + + var dir = Directory.CreateTempSubdirectory(); + + var file = Path.Combine(dir.FullName, "file.txt"); + + await File.WriteAllTextAsync(file, "This is root"); + + var handler = IoxideFilesModule.From(dir.FullName); + + // Edited after the cache snapshot was taken, with a different length so IsFresh's size check fails. + await File.WriteAllTextAsync(file, "This is the updated content"); + + await using var host = await TestHost.RunAsync(handler, engine: TestEngine.Ioxide); + + using var response = await host.GetResponseAsync("/file.txt"); + + await response.AssertStatusAsync(HttpStatusCode.OK); + + Assert.AreEqual("This is the updated content", await response.GetContentAsync()); + } + [TestMethod] public void TestChaining() { diff --git a/Testing/Acceptance/Modules/ReverseProxy/ReverseProxyTests.cs b/Testing/Acceptance/Modules/ReverseProxy/ReverseProxyTests.cs index ac765912..e0a978af 100644 --- a/Testing/Acceptance/Modules/ReverseProxy/ReverseProxyTests.cs +++ b/Testing/Acceptance/Modules/ReverseProxy/ReverseProxyTests.cs @@ -7,6 +7,8 @@ using GenHTTP.Modules.Layouting; using GenHTTP.Modules.ReverseProxy; +using GenHTTP.Testing.Acceptance.Utilities; + namespace GenHTTP.Testing.Acceptance.Modules.ReverseProxy; [TestClass] @@ -336,6 +338,91 @@ public async Task TestCompression(TestEngine engine) Assert.AreEqual("br", response.GetContentHeader("Content-Encoding")); } + [TestMethod] + [MultiEngineTest] + public async Task TestRedirectionToExternalHostIsNotRewritten(TestEngine engine) + { + await using var setup = await TestSetup.CreateAsync(engine, r => + { + return r.Respond().Header("Location", "https://example.com/elsewhere").Status(ResponseStatus.TemporaryRedirect).Build(); + }); + + using var redirected = await setup.Runner.GetResponseAsync("/"); + + Assert.AreEqual("https://example.com/elsewhere", redirected.GetHeader("Location")); + } + + [TestMethod] + [MultiEngineTest] + public async Task TestRedirectionFromScopedMount(TestEngine engine) + { + await using var upstream = new TestHost(Layout.Create().Build(), false, engine: engine); + + await upstream.Host.Handler(new ProxiedRouter(r => + r.Respond().Header("Location", $"http://localhost:{upstream.Port}/target").Status(ResponseStatus.TemporaryRedirect).Build())) + .StartAsync(); + + var proxy = Proxy.Create().Upstream("http://localhost:" + upstream.Port); + + await using var runner = await TestHost.RunAsync(Layout.Create().Add("api", proxy), engine: engine); + + using var redirected = await runner.GetResponseAsync("/api/whatever"); + + var location = redirected.GetHeader("Location"); + + Assert.IsNotNull(location); + AssertX.Contains($"http://localhost:{runner.Port}", location); + AssertX.Contains("/target", location); + } + + [TestMethod] + [MultiEngineTest] + public async Task TestForwardingByAddressIsRelayed(TestEngine engine) + { + await using var setup = await TestSetup.CreateAsync(engine, r => + { + var header = r.Header.Headers.GetEntry("Forwarded"); + + Assert.IsNotNull(header); + AssertX.Contains("by=203.0.113.5", header); + + return r.Respond().Content("Hello World!").Build(); + }); + + var request = setup.Runner.GetRequest(); + request.Headers.Add("Forwarded", "for=85.192.1.5; by=203.0.113.5; host=google.com"); + + using var response = await setup.Runner.GetResponseAsync(request); + Assert.AreEqual("Hello World!", await response.GetContentAsync()); + } + + [TestMethod] + [MultiEngineTest] + public async Task TestForwardingByAddressIsRelayedForIPv6(TestEngine engine) + { + await using var setup = await TestSetup.CreateAsync(engine, r => + { + var header = r.Header.Headers.GetEntry("Forwarded"); + + Assert.IsNotNull(header); + AssertX.Contains("by=[2001:db8::1]", header); + + return r.Respond().Content("Hello World!").Build(); + }); + + var request = setup.Runner.GetRequest(); + request.Headers.Add("Forwarded", "for=85.192.1.5; by=\"[2001:db8::1]\"; host=google.com"); + + using var response = await setup.Runner.GetResponseAsync(request); + Assert.AreEqual("Hello World!", await response.GetContentAsync()); + } + + [TestMethod] + public void TestChaining() + { + Chain.Works(Proxy.Create().Upstream("https://google.com")); + } + [TestMethod] public void TestAdjustments() { diff --git a/Testing/Acceptance/Modules/ReverseProxy/WebsocketProxy/RawWebsocketConnectionTests.cs b/Testing/Acceptance/Modules/ReverseProxy/WebsocketProxy/RawWebsocketConnectionTests.cs new file mode 100644 index 00000000..143b237c --- /dev/null +++ b/Testing/Acceptance/Modules/ReverseProxy/WebsocketProxy/RawWebsocketConnectionTests.cs @@ -0,0 +1,81 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; + +using GenHTTP.Api.Content; +using GenHTTP.Api.Infrastructure; +using GenHTTP.Api.Protocol; +using GenHTTP.Engine.Shared.Types; +using GenHTTP.Modules.ReverseProxy.Websocket; + +using NSubstitute; + +namespace GenHTTP.Testing.Acceptance.Modules.ReverseProxy.WebsocketProxy; + +[TestClass] +public sealed class RawWebsocketConnectionTests +{ + + [TestMethod] + public void TestInvalidUrlThrows() + { + Assert.ThrowsExactly(() => new RawWebsocketConnection("not a valid url")); + } + + [TestMethod] + public void TestDefaultPortsResolveForKnownAndUnknownSchemes() + { + _ = new RawWebsocketConnection("http://example.com/"); + _ = new RawWebsocketConnection("https://example.com/"); + _ = new RawWebsocketConnection("ws://example.com/"); + _ = new RawWebsocketConnection("wss://example.com/"); + _ = new RawWebsocketConnection("ftp://example.com/"); // unrecognized scheme - falls back to port 0 + } + + [TestMethod] + public async Task TestUpgradeWithoutInitializeThrows() + { + var connection = new RawWebsocketConnection("ws://example.com/"); + + // Pipe is null before InitializeStream() runs, so the guard fires before the request is + // ever touched - passing null is safe here. + await Assert.ThrowsExactlyAsync(() => connection.TryUpgrade(null!)); + } + + [TestMethod] + public async Task TestUpstreamClosesBeforeHandshakeCompletes() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + var acceptTask = Task.Run(async () => + { + using var socket = await listener.AcceptSocketAsync(); + + // Send a partial, incomplete handshake response, then close without the terminator. + await socket.SendAsync("HTTP/1.1 101 Switching"u8.ToArray(), SocketFlags.None); + socket.Shutdown(SocketShutdown.Send); + + // Give the peer time to observe the clean FIN before we tear the socket down. + await Task.Delay(200); + }); + + await using var connection = new RawWebsocketConnection($"ws://127.0.0.1:{port}/"); + await connection.InitializeStream(); + + var server = Substitute.For(); + server.Running.Returns(true); + + var request = new Request(); + request.Apply(server); + + var exception = await Assert.ThrowsExactlyAsync(() => connection.TryUpgrade(request)); + + Assert.AreEqual(ResponseStatus.BadGateway, exception.Status); + + await acceptTask; + } + +} diff --git a/Testing/Acceptance/Modules/ReverseProxy/WebsocketProxy/WebsocketTunnelTests.cs b/Testing/Acceptance/Modules/ReverseProxy/WebsocketProxy/WebsocketTunnelTests.cs index 084d7690..01ba13e5 100644 --- a/Testing/Acceptance/Modules/ReverseProxy/WebsocketProxy/WebsocketTunnelTests.cs +++ b/Testing/Acceptance/Modules/ReverseProxy/WebsocketProxy/WebsocketTunnelTests.cs @@ -1,14 +1,49 @@ +using System.Net; using System.Net.WebSockets; +using System.Security.Authentication; using System.Text; using GenHTTP.Modules.Layouting; using GenHTTP.Modules.ReverseProxy; +using GenHTTP.Testing.Acceptance.Utilities; + namespace GenHTTP.Testing.Acceptance.Modules.ReverseProxy.WebsocketProxy; [TestClass] public class WebsocketTunnelTests { + [TestMethod] + public async Task TestSecureUpstream() + { + await using var upstreamServer = new TestHost(Layout.Create().Build(), false); + + var securePort = TestHost.NextPort(); + var cert = await Utilities.Security.GetCertificateAsync(); + + upstreamServer.Host.Handler(GenHTTP.Modules.Websockets.Websocket.Functional() + .OnConnected(_ => ValueTask.CompletedTask) + .OnMessage(async (connection, message) => await connection.WriteAsync(message.Data)) + .OnClose((_, __) => ValueTask.CompletedTask)) + .Bind(IPAddress.Any, (ushort)securePort, cert, SslProtocols.Tls12); + + await upstreamServer.StartAsync(); + + var proxy = Proxy.Create().Upstream($"https://localhost:{securePort}"); + + await using var runner = await TestHost.RunAsync(proxy); + + // The proxy connects to the upstream over TLS using default (non-bypassed) certificate + // validation, so a self-signed test certificate is correctly rejected as untrusted - this + // exercises the SslStream upgrade path in RawWebsocketConnection, not a successful tunnel. + using var client = new ClientWebSocket(); + + var exception = await Assert.ThrowsExactlyAsync(() => + client.ConnectAsync(new Uri($"ws://localhost:{runner.Port}"), CancellationToken.None)); + + Assert.AreEqual(WebSocketError.NotAWebSocket, exception.WebSocketErrorCode); + } + [TestMethod] public async Task TestBasics() { diff --git a/Testing/Acceptance/Modules/Websockets/Integration/Imperative/FrameEdgeCaseTests.cs b/Testing/Acceptance/Modules/Websockets/Integration/Imperative/FrameEdgeCaseTests.cs new file mode 100644 index 00000000..28d17ecf --- /dev/null +++ b/Testing/Acceptance/Modules/Websockets/Integration/Imperative/FrameEdgeCaseTests.cs @@ -0,0 +1,121 @@ +using System.Text; + +using GenHTTP.Modules.Websockets; +using GenHTTP.Modules.Websockets.Protocol; + +using GenHTTP.Testing.Acceptance.Modules.Websockets.RawClient; + +namespace GenHTTP.Testing.Acceptance.Modules.Websockets.Integration.Imperative; + +[TestClass] +public sealed class FrameEdgeCaseTests +{ + + [TestMethod] + [MultiEngineTest] + public async Task TestPingInterruptsSegmentedMessage(TestEngine engine) + { + var websocket = GenHTTP.Modules.Websockets.Websocket.Imperative().Handler(new EchoAllHandler()); + + await using var host = await TestHost.RunAsync(websocket, engine: engine); + + await using var client = new RawWebSocketClient(); + await client.ConnectAsync("127.0.0.1", host.Port); + + // Text(fin=0) + Ping(fin=1) interleaved + Continuation(fin=1) completing the message + var first = RawWebSocketClient.BuildClientFrame("Hello "u8.ToArray(), opcode: 0x1, fin: false); + var ping = RawWebSocketClient.BuildClientFrame("ping-data"u8.ToArray(), opcode: 0x9, fin: true); + var last = RawWebSocketClient.BuildClientFrame("World"u8.ToArray(), opcode: 0x0, fin: true); + + var combined = first.Concat(ping).Concat(last).ToArray(); + await client.SendRawInChunksAsync(combined, chunkSize: combined.Length); + + var (pingResponseOpcode, _, pingResponsePayload) = await client.ReceiveFrameAsync(); + + Assert.AreEqual((byte)0xA, pingResponseOpcode); + Assert.AreEqual("ping-data", Encoding.UTF8.GetString(pingResponsePayload)); + + Assert.AreEqual("Hello World", await client.ReceiveTextFrameAsync()); + } + + [TestMethod] + [MultiEngineTest] + public async Task TestCloseInterruptsSegmentedMessage(TestEngine engine) + { + var websocket = GenHTTP.Modules.Websockets.Websocket.Imperative().Handler(new EchoAllHandler()); + + await using var host = await TestHost.RunAsync(websocket, engine: engine); + + await using var client = new RawWebSocketClient(); + await client.ConnectAsync("127.0.0.1", host.Port); + + // Text(fin=0) followed directly by a Close - the in-progress segmented message is abandoned + var first = RawWebSocketClient.BuildClientFrame("Hello "u8.ToArray(), opcode: 0x1, fin: false); + var close = RawWebSocketClient.BuildClientFrame([], opcode: 0x8, fin: true); + + var combined = first.Concat(close).ToArray(); + await client.SendRawInChunksAsync(combined, chunkSize: combined.Length); + + var (opcode, _, _) = await client.ReceiveFrameAsync(); + + Assert.AreEqual((byte)0x8, opcode); + } + + [TestMethod] + public async Task TestTruncatedFrameAtEofYieldsError() + { + // Internal only: Kestrel tears down the connection outright on a half-closed upgrade + // stream instead of letting the handler write a response - a deeper ASP.NET Core + // transport behavior, not something specific to this (engine-agnostic) code path. + var websocket = GenHTTP.Modules.Websockets.Websocket.Imperative().Handler(new EchoAllHandler()); + + await using var host = await TestHost.RunAsync(websocket, engine: TestEngine.Internal); + + await using var client = new RawWebSocketClient(); + await client.ConnectAsync("127.0.0.1", host.Port); + + var frame = RawWebSocketClient.BuildClientFrame("hello"u8.ToArray(), opcode: 0x1, fin: true); + + // header + mask only (6 bytes for a small payload) - the payload never arrives + var partial = frame[..6]; + + await client.SendRawInChunksAsync(partial, chunkSize: partial.Length); + + client.ShutdownWrite(); + + Assert.AreEqual(FrameError.UnexpectedEndOfStream, await client.ReceiveTextFrameAsync()); + } + + private sealed class EchoAllHandler : IImperativeHandler + { + public async ValueTask HandleAsync(IImperativeConnection connection) + { + while (true) + { + var frame = await connection.ReadFrameAsync(); + + if (frame.Type == FrameType.Close) + { + await connection.CloseAsync(); + return; + } + + if (frame.Type == FrameType.Ping) + { + await connection.PongAsync(frame.Data); + continue; + } + + if (frame.IsError(out var error)) + { + await connection.WriteAsync(Encoding.UTF8.GetBytes(error.Message)); + await connection.CloseAsync(); + return; + } + + await connection.WriteAsync(frame.Data); + } + } + } + +} diff --git a/Testing/Acceptance/Modules/Websockets/Integration/Pipelining/PipeliningTests.cs b/Testing/Acceptance/Modules/Websockets/Integration/Pipelining/PipeliningTests.cs index e8fc6104..dd42436b 100644 --- a/Testing/Acceptance/Modules/Websockets/Integration/Pipelining/PipeliningTests.cs +++ b/Testing/Acceptance/Modules/Websockets/Integration/Pipelining/PipeliningTests.cs @@ -1,3 +1,5 @@ +using System.Text; + using GenHTTP.Modules.Websockets; using GenHTTP.Modules.Websockets.Protocol; @@ -180,10 +182,56 @@ public async Task TestTryReadFrameReturnsFalseOnPartialFrame(TestEngine engine) Assert.AreEqual("second", await client.ReceiveTextFrameAsync()); } + /// + /// A client-sent ping should be answered with a pong that either carries the ping's + /// payload or is empty, using both of the interface's PongAsync overloads. + /// + [TestMethod] + [MultiEngineTest] + public async Task TestPongOverloads(TestEngine engine) + { + var websocket = GenHTTP.Modules.Websockets.Websocket + .Imperative() + .Handler(new PongHandler()); + + await using var host = await TestHost.RunAsync(websocket, engine: engine); + + await using var client = new RawWebSocketClient(); + await client.ConnectAsync("127.0.0.1", host.Port); + + await client.SendRawInChunksAsync(RawWebSocketClient.BuildClientFrame("ping-data"u8.ToArray(), opcode: 0x9, fin: true), chunkSize: 64); + + var (opcode1, _, payload1) = await client.ReceiveFrameAsync(); + var (opcode2, _, payload2) = await client.ReceiveFrameAsync(); + + Assert.AreEqual((byte)0xA, opcode1); + Assert.AreEqual("ping-data", Encoding.UTF8.GetString(payload1)); + + Assert.AreEqual((byte)0xA, opcode2); + Assert.AreEqual(0, payload2.Length); + + Assert.AreEqual("done", await client.ReceiveTextFrameAsync()); + } + // ------------------------------------------------------------------------- // Handlers // ------------------------------------------------------------------------- + private sealed class PongHandler : IImperativeHandler + { + public async ValueTask HandleAsync(IImperativeConnection connection) + { + var frame = await connection.ReadFrameAsync(); + if (frame.Type == FrameType.Close) return; + + await connection.PongAsync(frame.Data, flush: false); + await connection.PongAsync(flush: false); + await connection.WriteAsync("done"u8.ToArray()); + + await connection.CloseAsync(); + } + } + private sealed class BatchHandler : IImperativeHandler { public async ValueTask HandleAsync(IImperativeConnection connection) diff --git a/Testing/Acceptance/Modules/Websockets/RawClient/RawWebsocketClient.cs b/Testing/Acceptance/Modules/Websockets/RawClient/RawWebsocketClient.cs index 0b606055..5385a0c7 100644 --- a/Testing/Acceptance/Modules/Websockets/RawClient/RawWebsocketClient.cs +++ b/Testing/Acceptance/Modules/Websockets/RawClient/RawWebsocketClient.cs @@ -288,6 +288,62 @@ public async Task ReceiveTextFrameAsync(CancellationToken token = defaul return Encoding.UTF8.GetString(payload); } + /// + /// Receive a single raw WebSocket frame (any opcode) and return its opcode, FIN bit and payload. + /// + public async Task<(byte Opcode, bool Fin, byte[] Payload)> ReceiveFrameAsync(CancellationToken token = default) + { + var header = new byte[2]; + await ReceiveExactAsync(header, token).ConfigureAwait(false); + + var b0 = header[0]; + var b1 = header[1]; + + var fin = (b0 & 0x80) != 0; + var opcode = (byte)(b0 & 0x0F); + + var masked = (b1 & 0x80) != 0; + var len7 = (byte)(b1 & 0x7F); + + long payloadLen = len7; + + if (len7 == 126) + { + var lenBytes = new byte[2]; + await ReceiveExactAsync(lenBytes, token).ConfigureAwait(false); + payloadLen = BinaryPrimitives.ReadUInt16BigEndian(lenBytes); + } + else if (len7 == 127) + { + var lenBytes = new byte[8]; + await ReceiveExactAsync(lenBytes, token).ConfigureAwait(false); + payloadLen = (long)BinaryPrimitives.ReadUInt64BigEndian(lenBytes); + } + + byte[]? maskKey = null; + if (masked) + { + maskKey = new byte[4]; + await ReceiveExactAsync(maskKey, token).ConfigureAwait(false); + } + + var payload = new byte[payloadLen]; + if (payloadLen > 0) + { + await ReceiveExactAsync(payload.AsMemory(), token).ConfigureAwait(false); + } + + if (masked && maskKey is not null) + { + for (var i = 0; i < payload.Length; i++) + { + payload[i] ^= maskKey[i & 0x03]; + } + } + + return (opcode, fin, payload); + } + public Task SendTextAsContinuationFramesInTcpChunksAsync( string text, int wsFragmentPayloadSize, @@ -335,6 +391,11 @@ public Task SendTextAsContinuationFramesInTcpChunksAsync( return SendRawInChunksAsync(buffer, tcpChunkSize, token); } + /// + /// Half-closes the write side of the connection (sends FIN) while leaving the read side open. + /// + public void ShutdownWrite() => _socket.Shutdown(SocketShutdown.Send); + public ValueTask DisposeAsync() { try