From de8bec310b9e4e7a1c2244306bc735c8a774ff91 Mon Sep 17 00:00:00 2001 From: justcoding121 Date: Sun, 2 Aug 2026 17:26:47 -0600 Subject: [PATCH 1/7] ci: exclude examples and benchmarks from Sonar coverage Demo and BenchmarkDotNet projects inflate uncovered new-code lines without exercising the proxy library. Keep them out of the coverage denominator so the quality gate reflects product code. Co-authored-by: Cursor --- .github/workflows/dotnetcore.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/dotnetcore.yml b/.github/workflows/dotnetcore.yml index 2538b6b8..9c5d0118 100644 --- a/.github/workflows/dotnetcore.yml +++ b/.github/workflows/dotnetcore.yml @@ -57,6 +57,7 @@ jobs: /o:"justcoding121" /d:sonar.token="$env:SONAR_TOKEN" /d:sonar.cs.vscoveragexml.reportsPaths="coverage/coverage.xml" + /d:sonar.coverage.exclusions="**/examples/**,**/benchmarks/**" - name: Build for SonarCloud analysis if: env.SONAR_TOKEN != '' From a307e311bd7bbbf44759af6bf78f7a208a04d5a1 Mon Sep 17 00:00:00 2001 From: justcoding121 Date: Sun, 2 Aug 2026 17:30:51 -0600 Subject: [PATCH 2/7] test: cover HTTP/3 frame, settings, and QPACK stream codecs Accept Stream for frame/varint/QPACK I/O so MemoryStream unit tests can exercise encode/decode, SETTINGS round-trip, encoder-stream instructions, and endpoint/exception models without MsQuic. Co-authored-by: Cursor --- src/Titanium.Web.Proxy/Http3/Http3Frame.cs | 12 +- src/Titanium.Web.Proxy/Http3/Http3VarInt.cs | 10 +- .../Http3/Qpack/QpackDecoderStreamWriter.cs | 6 +- .../Http3/Qpack/QpackEncoderStreamReader.cs | 9 +- .../Http3FrameTests.cs | 113 +++++++++++++ .../Http3ModelAndEndpointTests.cs | 130 +++++++++++++++ .../Http3SettingsTests.cs | 78 +++++++++ .../Http3VarIntStreamTests.cs | 58 +++++++ .../QpackDecoderStreamWriterTests.cs | 42 +++++ .../QpackEncoderStreamReaderTests.cs | 154 ++++++++++++++++++ 10 files changed, 589 insertions(+), 23 deletions(-) create mode 100644 tests/Titanium.Web.Proxy.UnitTests/Http3FrameTests.cs create mode 100644 tests/Titanium.Web.Proxy.UnitTests/Http3ModelAndEndpointTests.cs create mode 100644 tests/Titanium.Web.Proxy.UnitTests/Http3SettingsTests.cs create mode 100644 tests/Titanium.Web.Proxy.UnitTests/Http3VarIntStreamTests.cs create mode 100644 tests/Titanium.Web.Proxy.UnitTests/QpackDecoderStreamWriterTests.cs create mode 100644 tests/Titanium.Web.Proxy.UnitTests/QpackEncoderStreamReaderTests.cs diff --git a/src/Titanium.Web.Proxy/Http3/Http3Frame.cs b/src/Titanium.Web.Proxy/Http3/Http3Frame.cs index 15b37e5d..67be173a 100644 --- a/src/Titanium.Web.Proxy/Http3/Http3Frame.cs +++ b/src/Titanium.Web.Proxy/Http3/Http3Frame.cs @@ -1,15 +1,14 @@ using System; -using System.Net.Quic; +using System.IO; using System.Threading; using System.Threading.Tasks; namespace Titanium.Web.Proxy.Http3; /// -/// HTTP/3 frame as read from or written to a QUIC stream. +/// HTTP/3 frame as read from or written to a stream (typically a QUIC stream). /// Format: Type (VarInt) | Length (VarInt) | Payload (Length bytes) (RFC 9114 §7.1). /// -#pragma warning disable CA1416 internal sealed class Http3Frame { public ulong Type { get; init; } @@ -21,7 +20,7 @@ internal sealed class Http3Frame /// /// On malformed frame (e.g., huge payload). public static async ValueTask ReadAsync( - QuicStream stream, + Stream stream, long maxPayloadBytes, CancellationToken cancellationToken) { @@ -61,7 +60,7 @@ internal sealed class Http3Frame /// Writes a frame (type + length + payload) to . /// public static async ValueTask WriteAsync( - QuicStream stream, + Stream stream, ulong frameType, ReadOnlyMemory payload, CancellationToken cancellationToken) @@ -83,9 +82,8 @@ public static async ValueTask WriteAsync( /// Writes a zero-payload frame (used for GOAWAY and some SETTINGS without parameters). /// public static async ValueTask WriteAsync( - QuicStream stream, + Stream stream, ulong frameType, CancellationToken cancellationToken) => await WriteAsync(stream, frameType, ReadOnlyMemory.Empty, cancellationToken); } -#pragma warning restore CA1416 diff --git a/src/Titanium.Web.Proxy/Http3/Http3VarInt.cs b/src/Titanium.Web.Proxy/Http3/Http3VarInt.cs index dec7ebcc..db4fa466 100644 --- a/src/Titanium.Web.Proxy/Http3/Http3VarInt.cs +++ b/src/Titanium.Web.Proxy/Http3/Http3VarInt.cs @@ -1,7 +1,5 @@ using System; -using System.Buffers; using System.IO; -using System.Net.Quic; using System.Threading; using System.Threading.Tasks; @@ -15,7 +13,6 @@ namespace Titanium.Web.Proxy.Http3; /// 10 = 4 bytes (30-bit value), 11 = 8 bytes (62-bit value). /// /// -#pragma warning disable CA1416 internal static class Http3VarInt { public const ulong Max1ByteValue = (1UL << 6) - 1; // 63 @@ -125,10 +122,10 @@ public static bool TryRead(ReadOnlySpan source, out ulong value, out int b } /// - /// Reads a variable-length integer from a . + /// Reads a variable-length integer from a (including ). /// Returns when the stream ends before a complete integer arrives. /// - public static async ValueTask ReadAsync(QuicStream stream, CancellationToken cancellationToken) + public static async ValueTask ReadAsync(Stream stream, CancellationToken cancellationToken) { var oneByte = new byte[1]; if (!await ReadExactAsync(stream, oneByte, cancellationToken)) return null; @@ -151,7 +148,7 @@ public static bool TryRead(ReadOnlySpan source, out ulong value, out int b return value; } - private static async ValueTask ReadExactAsync(QuicStream stream, Memory buffer, CancellationToken ct) + private static async ValueTask ReadExactAsync(Stream stream, Memory buffer, CancellationToken ct) { var offset = 0; while (offset < buffer.Length) @@ -163,4 +160,3 @@ private static async ValueTask ReadExactAsync(QuicStream stream, Memory until the channel is completed or is cancelled. /// - internal static async Task RunAsync(QuicStream stream, QpackContext context, CancellationToken ct) + internal static async Task RunAsync(Stream stream, QpackContext context, CancellationToken ct) { var reader = context.DecoderAckChannel.Reader; @@ -33,4 +32,3 @@ internal static async Task RunAsync(QuicStream stream, QpackContext context, Can catch (Exception) { /* stream closed — stop writing */ } } } -#pragma warning restore CA1416 diff --git a/src/Titanium.Web.Proxy/Http3/Qpack/QpackEncoderStreamReader.cs b/src/Titanium.Web.Proxy/Http3/Qpack/QpackEncoderStreamReader.cs index 8d79a16e..1fa0b1b1 100644 --- a/src/Titanium.Web.Proxy/Http3/Qpack/QpackEncoderStreamReader.cs +++ b/src/Titanium.Web.Proxy/Http3/Qpack/QpackEncoderStreamReader.cs @@ -1,6 +1,5 @@ -#pragma warning disable CA1416 using System; -using System.Net.Quic; +using System.IO; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -33,7 +32,7 @@ internal static class QpackEncoderStreamReader /// dynamic-table insertion silently. /// /// - internal static async Task ProcessAsync(QuicStream stream, QpackContext context, CancellationToken ct) + internal static async Task ProcessAsync(Stream stream, QpackContext context, CancellationToken ct) { var readBuffer = new byte[4096]; var pending = Array.Empty(); @@ -80,8 +79,9 @@ internal static async Task ProcessAsync(QuicStream stream, QpackContext context, /// silent no-op (matching the pre-existing, deliberately lenient behavior here) rather than /// torn down as a connection error, since the entry may simply have been evicted by the time /// this reader catches up - only a truncated instruction at end-of-stream is fatal. + /// Exposed internally for unit tests that feed crafted instruction bytes without a live QUIC stream. /// - private static bool TryParseOneInstruction(ReadOnlySpan data, QpackContext context, out int consumed) + internal static bool TryParseOneInstruction(ReadOnlySpan data, QpackContext context, out int consumed) { consumed = 0; if (data.IsEmpty) return false; @@ -183,4 +183,3 @@ private static bool TryReadStringLiteral(ReadOnlySpan data, out string res return true; } } -#pragma warning restore CA1416 diff --git a/tests/Titanium.Web.Proxy.UnitTests/Http3FrameTests.cs b/tests/Titanium.Web.Proxy.UnitTests/Http3FrameTests.cs new file mode 100644 index 00000000..9efd51b7 --- /dev/null +++ b/tests/Titanium.Web.Proxy.UnitTests/Http3FrameTests.cs @@ -0,0 +1,113 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Titanium.Web.Proxy.Http3; + +namespace Titanium.Web.Proxy.UnitTests; + +/// +/// Unit coverage for HTTP/3 frame encode/decode over (RFC 9114 §7.1). +/// +[TestClass] +public class Http3FrameTests +{ + [TestMethod] + public async Task WriteThenRead_RoundTripsTypeAndPayload() + { + await using var ms = new MemoryStream(); + var payload = new byte[] { 0x01, 0x02, 0x03 }; + + await Http3Frame.WriteAsync(ms, Http3FrameType.Data, payload, CancellationToken.None); + ms.Position = 0; + + var frame = await Http3Frame.ReadAsync(ms, maxPayloadBytes: 1024, CancellationToken.None); + + Assert.IsNotNull(frame); + Assert.AreEqual(Http3FrameType.Data, frame!.Type); + CollectionAssert.AreEqual(payload, frame.Payload.ToArray()); + } + + [TestMethod] + public async Task WriteThenRead_ZeroPayloadFrame_RoundTrips() + { + await using var ms = new MemoryStream(); + + await Http3Frame.WriteAsync(ms, Http3FrameType.Settings, CancellationToken.None); + ms.Position = 0; + + var frame = await Http3Frame.ReadAsync(ms, maxPayloadBytes: 0, CancellationToken.None); + + Assert.IsNotNull(frame); + Assert.AreEqual(Http3FrameType.Settings, frame!.Type); + Assert.AreEqual(0, frame.Payload.Length); + } + + [TestMethod] + public async Task ReadAsync_EmptyStream_ReturnsNull() + { + await using var ms = new MemoryStream(); + var frame = await Http3Frame.ReadAsync(ms, maxPayloadBytes: 1024, CancellationToken.None); + Assert.IsNull(frame); + } + + [TestMethod] + public async Task ReadAsync_OversizedPayload_ThrowsExcessiveLoad() + { + await using var ms = new MemoryStream(); + var payload = new byte[64]; + await Http3Frame.WriteAsync(ms, Http3FrameType.Data, payload, CancellationToken.None); + ms.Position = 0; + + var ex = await Assert.ThrowsExceptionAsync( + () => Http3Frame.ReadAsync(ms, maxPayloadBytes: 16, CancellationToken.None).AsTask()); + + Assert.AreEqual(Http3ErrorCode.ExcessiveLoad, ex.ErrorCode); + } + + [TestMethod] + public async Task ReadAsync_TruncatedPayload_ThrowsFrameError() + { + await using var ms = new MemoryStream(); + // Type=DATA (0), Length=10, but only 3 payload bytes written. + ms.WriteByte(0x00); // type + ms.WriteByte(0x0A); // length 10 + ms.Write(new byte[] { 1, 2, 3 }); + ms.Position = 0; + + var ex = await Assert.ThrowsExceptionAsync( + () => Http3Frame.ReadAsync(ms, maxPayloadBytes: 0, CancellationToken.None).AsTask()); + + Assert.AreEqual(Http3ErrorCode.FrameError, ex.ErrorCode); + } + + [TestMethod] + public async Task ReadAsync_TruncatedAfterType_ThrowsFrameError() + { + await using var ms = new MemoryStream(); + ms.WriteByte(0x00); // type only — length missing + ms.Position = 0; + + var ex = await Assert.ThrowsExceptionAsync( + () => Http3Frame.ReadAsync(ms, maxPayloadBytes: 0, CancellationToken.None).AsTask()); + + Assert.AreEqual(Http3ErrorCode.FrameError, ex.ErrorCode); + } + + [TestMethod] + public async Task WriteThenRead_HeadersFrame_PreservesQpackBytes() + { + await using var ms = new MemoryStream(); + var qpack = new byte[] { 0x00, 0x00, 0xD1 }; // typical empty RIC/base + indexed status + + await Http3Frame.WriteAsync(ms, Http3FrameType.Headers, qpack, CancellationToken.None); + ms.Position = 0; + + var frame = await Http3Frame.ReadAsync(ms, maxPayloadBytes: 4096, CancellationToken.None); + + Assert.IsNotNull(frame); + Assert.AreEqual(Http3FrameType.Headers, frame!.Type); + CollectionAssert.AreEqual(qpack, frame.Payload.ToArray()); + } +} diff --git a/tests/Titanium.Web.Proxy.UnitTests/Http3ModelAndEndpointTests.cs b/tests/Titanium.Web.Proxy.UnitTests/Http3ModelAndEndpointTests.cs new file mode 100644 index 00000000..6c981bf8 --- /dev/null +++ b/tests/Titanium.Web.Proxy.UnitTests/Http3ModelAndEndpointTests.cs @@ -0,0 +1,130 @@ +#pragma warning disable TWP001 // Experimental H3 API — intentional in tests + +using System; +using System.Net; +using System.Threading; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Titanium.Web.Proxy.EventArguments; +using Titanium.Web.Proxy.Helpers; +using Titanium.Web.Proxy.Http3; +using Titanium.Web.Proxy.Models; +using Titanium.Web.Proxy.Network.Tcp; + +namespace Titanium.Web.Proxy.UnitTests; + +/// +/// Unit coverage for small HTTP/3 model types and transparent QUIC endpoint defaults. +/// +[TestClass] +public class Http3ModelAndEndpointTests +{ + [TestMethod] + public void Http3ConnectionException_StoresErrorCode() + { + var ex = new Http3ConnectionException(Http3ErrorCode.MissingSettings, "no settings"); + Assert.AreEqual(Http3ErrorCode.MissingSettings, ex.ErrorCode); + Assert.AreEqual("no settings", ex.Message); + } + + [TestMethod] + public void Http3StreamException_StoresErrorCode() + { + var ex = new Http3StreamException(Http3ErrorCode.FrameUnexpected, "bad frame"); + Assert.AreEqual(Http3ErrorCode.FrameUnexpected, ex.ErrorCode); + Assert.AreEqual("bad frame", ex.Message); + } + + [TestMethod] + public void Http3StreamState_IsClosed_RequiresBothHalves() + { + using var proxy = new ProxyServer(false, false, false); + var endPoint = new TransparentQuicProxyEndPoint(IPAddress.Loopback, 0); + using var connection = new QuicClientConnection( + proxy, new IPEndPoint(IPAddress.Loopback, 4433), new IPEndPoint(IPAddress.Loopback, 12345)); + using var cts = new CancellationTokenSource(); + var clientStream = new HttpClientStream(proxy, connection, System.IO.Stream.Null, proxy.BufferPool, cts.Token); + var session = new SessionEventArgs(proxy, endPoint, clientStream, null, cts); + + var state = new Http3StreamState(streamId: 0, session, cts); + + Assert.IsFalse(state.IsClosed); + state.RequestClosed = true; + Assert.IsFalse(state.IsClosed); + state.ResponseClosed = true; + Assert.IsTrue(state.IsClosed); + Assert.AreEqual(0L, state.StreamId); + Assert.AreSame(session, state.SessionArgs); + Assert.AreSame(cts, state.Cancellation); + Assert.AreEqual(0, state.FinalizedFlag); + } + + [TestMethod] + public void TransparentQuicProxyEndPoint_Defaults_AreSane() + { + var ep = new TransparentQuicProxyEndPoint(IPAddress.Loopback, 4433); + + Assert.AreEqual("localhost", ep.GenericCertificateName); + Assert.AreEqual(100, ep.MaxInboundBidirectionalStreams); + Assert.AreEqual(3, ep.MaxInboundUnidirectionalStreams); + Assert.AreEqual(TimeSpan.FromSeconds(30), ep.HandshakeTimeout); + Assert.AreEqual(TimeSpan.FromSeconds(60), ep.IdleTimeout); + Assert.IsFalse(ep.AdvertiseToHttpClients); + Assert.IsNull(ep.OriginalDestinationResolver); + } + + [TestMethod] + public void TransparentQuicProxyEndPoint_UnidirectionalStreams_ClampedToAtLeastThree() + { + var ep = new TransparentQuicProxyEndPoint(0); + ep.MaxInboundUnidirectionalStreams = 1; + Assert.AreEqual(3, ep.MaxInboundUnidirectionalStreams); + + ep.MaxInboundUnidirectionalStreams = 10; + Assert.AreEqual(10, ep.MaxInboundUnidirectionalStreams); + } + + [TestMethod] + public void TransparentQuicProxyEndPoint_InvokeBeforeSslAuthenticate_IsNoOp() + { + using var proxy = new ProxyServer(false, false, false); + var ep = new TransparentQuicProxyEndPoint(IPAddress.Loopback, 0); + using var connection = new QuicClientConnection( + proxy, new IPEndPoint(IPAddress.Loopback, 4433), new IPEndPoint(IPAddress.Loopback, 12345)); + using var cts = new CancellationTokenSource(); + var args = new BeforeSslAuthenticateEventArgs(proxy, connection, cts, "example.com"); + + // Must complete immediately — QUIC uses BeforeQuicAuthenticate instead. + Assert.IsTrue(ep.InvokeBeforeSslAuthenticate(proxy, args, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance) + .IsCompletedSuccessfully); + } + + [TestMethod] + public void BeforeQuicAuthenticateEventArgs_DefaultsAndReject() + { + using var proxy = new ProxyServer(false, false, false); + using var cts = new CancellationTokenSource(); + var remote = new IPEndPoint(IPAddress.Loopback, 12345); + var local = new IPEndPoint(IPAddress.Loopback, 4433); + + var args = new BeforeQuicAuthenticateEventArgs( + proxy, cts, "sni.example", "origin.example", 443, remote, local); + + Assert.AreEqual("sni.example", args.SniHostName); + Assert.AreEqual("origin.example", args.OriginalDestinationHost); + Assert.AreEqual(443, args.OriginalDestinationPort); + Assert.AreEqual("origin.example", args.ForwardHost); + Assert.AreEqual(443, args.ForwardPort); + Assert.AreEqual(UpstreamHttpProtocol.Auto, args.UpstreamHttpProtocol); + Assert.IsTrue(args.AllowHttpProtocolTranslation); + Assert.IsNull(args.CustomUpStreamProxy); + + args.UpstreamHttpProtocol = UpstreamHttpProtocol.Http3; + Assert.AreEqual(UpstreamHttpProtocol.Http3, args.UpstreamHttpProtocol); + + Assert.ThrowsException( + () => args.UpstreamHttpProtocol = (UpstreamHttpProtocol)999); + + args.Reject(); + Assert.IsTrue(cts.IsCancellationRequested); + } +} diff --git a/tests/Titanium.Web.Proxy.UnitTests/Http3SettingsTests.cs b/tests/Titanium.Web.Proxy.UnitTests/Http3SettingsTests.cs new file mode 100644 index 00000000..031734ea --- /dev/null +++ b/tests/Titanium.Web.Proxy.UnitTests/Http3SettingsTests.cs @@ -0,0 +1,78 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Titanium.Web.Proxy.Http3; + +namespace Titanium.Web.Proxy.UnitTests; + +/// +/// Unit coverage for HTTP/3 SETTINGS parse/serialize (RFC 9114 §7.2.4 + QPACK). +/// +[TestClass] +public class Http3SettingsTests +{ + [TestMethod] + public void SerializeThenParse_RoundTripsKnownParameters() + { + var original = new Http3Settings(); + original.Add(Http3SettingsId.MaxFieldSectionSize, 65536); + original.SetQpackMaxTableCapacity(4096); + original.SetQpackBlockedStreams(0); + + var parsed = Http3Settings.Parse(original.Serialize()); + + Assert.AreEqual(65536UL, parsed.MaxFieldSectionSize); + Assert.AreEqual(4096u, parsed.QpackMaxTableCapacity); + Assert.AreEqual(0u, parsed.QpackBlockedStreams); + Assert.AreEqual(3, parsed.Parameters.Count); + } + + [TestMethod] + public void Parse_EmptyPayload_ReturnsDefaults() + { + var settings = Http3Settings.Parse(ReadOnlySpan.Empty); + + Assert.AreEqual(0UL, settings.MaxFieldSectionSize); + Assert.AreEqual(0u, settings.QpackMaxTableCapacity); + Assert.AreEqual(0u, settings.QpackBlockedStreams); + Assert.AreEqual(0, settings.Parameters.Count); + } + + [TestMethod] + public void Parse_UnknownSettingId_IsPreservedButIgnoredForTypedProperties() + { + var settings = new Http3Settings(); + settings.Add(0x2A, 99); // unknown id — must be ignored per RFC 9114 + + var parsed = Http3Settings.Parse(settings.Serialize()); + + Assert.AreEqual(1, parsed.Parameters.Count); + Assert.AreEqual(0x2AUL, parsed.Parameters[0].Id); + Assert.AreEqual(99UL, parsed.Parameters[0].Value); + Assert.AreEqual(0UL, parsed.MaxFieldSectionSize); + } + + [TestMethod] + public void Parse_TruncatedPair_StopsWithoutThrowing() + { + // Complete (id=6, value=1) then a truncated second id. + var buf = new byte[8]; + var offset = 0; + offset += Http3VarInt.Write(buf.AsSpan(offset), Http3SettingsId.MaxFieldSectionSize); + offset += Http3VarInt.Write(buf.AsSpan(offset), 1); + buf[offset] = 0x40; // starts a 2-byte varint with no second byte + + var parsed = Http3Settings.Parse(buf.AsSpan(0, offset + 1)); + + Assert.AreEqual(1UL, parsed.MaxFieldSectionSize); + Assert.AreEqual(1, parsed.Parameters.Count); + } + + [TestMethod] + public void Add_QpackMaxTableCapacity_ClampsToUIntMax() + { + var settings = new Http3Settings(); + settings.Add(Http3SettingsId.QpackMaxTableCapacity, (ulong)uint.MaxValue + 10); + + Assert.AreEqual(uint.MaxValue, settings.QpackMaxTableCapacity); + } +} diff --git a/tests/Titanium.Web.Proxy.UnitTests/Http3VarIntStreamTests.cs b/tests/Titanium.Web.Proxy.UnitTests/Http3VarIntStreamTests.cs new file mode 100644 index 00000000..d39f3ca4 --- /dev/null +++ b/tests/Titanium.Web.Proxy.UnitTests/Http3VarIntStreamTests.cs @@ -0,0 +1,58 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Titanium.Web.Proxy.Http3; + +namespace Titanium.Web.Proxy.UnitTests; + +/// +/// Stream-based coverage (span APIs are covered separately). +/// +[TestClass] +public class Http3VarIntStreamTests +{ + [TestMethod] + [DataRow(0UL)] + [DataRow(63UL)] + [DataRow(64UL)] + [DataRow(16383UL)] + [DataRow(16384UL)] + [DataRow(1UL << 30)] + public async Task ReadAsync_RoundTripsEncodedValue(ulong value) + { + var buf = new byte[8]; + var written = Http3VarInt.Write(buf, value); + await using var ms = new MemoryStream(buf, 0, written); + + var decoded = await Http3VarInt.ReadAsync(ms, CancellationToken.None); + + Assert.IsTrue(decoded.HasValue); + Assert.AreEqual(value, decoded!.Value); + } + + [TestMethod] + public async Task ReadAsync_EmptyStream_ReturnsNull() + { + await using var ms = new MemoryStream(); + var decoded = await Http3VarInt.ReadAsync(ms, CancellationToken.None); + Assert.IsNull(decoded); + } + + [TestMethod] + public async Task ReadAsync_TruncatedMultiByte_ReturnsNull() + { + // Prefix claims 2-byte encoding but only the first byte is present. + await using var ms = new MemoryStream([0x40]); + var decoded = await Http3VarInt.ReadAsync(ms, CancellationToken.None); + Assert.IsNull(decoded); + } + + [TestMethod] + public void GetByteCount_OverMax_Throws() + { + Assert.ThrowsException( + () => Http3VarInt.GetByteCount(Http3VarInt.Max8ByteValue + 1)); + } +} diff --git a/tests/Titanium.Web.Proxy.UnitTests/QpackDecoderStreamWriterTests.cs b/tests/Titanium.Web.Proxy.UnitTests/QpackDecoderStreamWriterTests.cs new file mode 100644 index 00000000..028b90aa --- /dev/null +++ b/tests/Titanium.Web.Proxy.UnitTests/QpackDecoderStreamWriterTests.cs @@ -0,0 +1,42 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Titanium.Web.Proxy.Http3.Qpack; + +namespace Titanium.Web.Proxy.UnitTests; + +/// +/// Unit coverage for draining the decoder ack channel. +/// +[TestClass] +public class QpackDecoderStreamWriterTests +{ + [TestMethod] + public async Task RunAsync_WritesEnqueuedSectionAckThenCompletes() + { + await using var ctx = new QpackContext(4096); + await using var ms = new MemoryStream(); + + ctx.EnqueueSectionAck(streamId: 4); + ctx.DecoderAckChannel.Writer.TryComplete(); + + await QpackDecoderStreamWriter.RunAsync(ms, ctx, CancellationToken.None); + + Assert.IsTrue(ms.Length > 0, "Expected at least one decoder-stream instruction byte."); + // Section Ack for stream 4 fits in one byte: 0x80 | 4 = 0x84 + Assert.AreEqual(0x84, ms.ToArray()[0]); + } + + [TestMethod] + public async Task RunAsync_Cancelled_ExitsWithoutThrowing() + { + await using var ctx = new QpackContext(4096); + await using var ms = new MemoryStream(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await QpackDecoderStreamWriter.RunAsync(ms, ctx, cts.Token); + } +} diff --git a/tests/Titanium.Web.Proxy.UnitTests/QpackEncoderStreamReaderTests.cs b/tests/Titanium.Web.Proxy.UnitTests/QpackEncoderStreamReaderTests.cs new file mode 100644 index 00000000..f01b3463 --- /dev/null +++ b/tests/Titanium.Web.Proxy.UnitTests/QpackEncoderStreamReaderTests.cs @@ -0,0 +1,154 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Titanium.Web.Proxy.Http3; +using Titanium.Web.Proxy.Http3.Qpack; + +namespace Titanium.Web.Proxy.UnitTests; + +/// +/// Unit coverage for QPACK encoder-stream instruction parsing (RFC 9204 §3.2). +/// +[TestClass] +public class QpackEncoderStreamReaderTests +{ + [TestMethod] + public async Task TryParse_SetDynamicTableCapacity_AppliesCapacity() + { + await using var ctx = new QpackContext(4096); + // Set Capacity 100: 01 + 6-bit prefix. 100 > 63 → 0x7F then remainder 37. + byte[] instruction = [0x7F, 37]; + + Assert.IsTrue(QpackEncoderStreamReader.TryParseOneInstruction(instruction, ctx, out var consumed)); + Assert.AreEqual(2, consumed); + Assert.AreEqual(100u, ctx.InboundDecoderTable.Capacity); + } + + [TestMethod] + public async Task TryParse_InsertWithLiteralName_InsertsEntry() + { + await using var ctx = new QpackContext(4096); + var instruction = EncodeInsertLiteral("x-custom", "abc"); + + Assert.IsTrue(QpackEncoderStreamReader.TryParseOneInstruction(instruction, ctx, out _)); + Assert.AreEqual(1UL, ctx.InboundDecoderTable.InsertCount); + Assert.IsTrue(ctx.InboundDecoderTable.TryGetByAbsoluteIndex(0, out var name, out var value)); + Assert.AreEqual("x-custom", name); + Assert.AreEqual("abc", value); + } + + [TestMethod] + public async Task TryParse_InsertWithStaticNameReference_UsesStaticTableName() + { + await using var ctx = new QpackContext(4096); + // Static table index 25 is commonly ":method" / related — use index 17 ("content-type") if present. + // Encode Insert With Name Reference, static, index that fits in 6 bits, value "text/plain". + // Format: 1 S=1 T=0 Index(6) + value literal. + // Index 25 (< 63): first byte = 0x80 | 0x40 | 25 = 0xD9 + var valueBytes = Encoding.Latin1.GetBytes("text/plain"); + var instruction = new byte[1 + 1 + valueBytes.Length]; + instruction[0] = 0xD9; // static name ref index 25 + instruction[1] = (byte)valueBytes.Length; // non-huffman length (fits in 7 bits) + valueBytes.CopyTo(instruction.AsSpan(2)); + + Assert.IsTrue(QpackEncoderStreamReader.TryParseOneInstruction(instruction, ctx, out _)); + Assert.AreEqual(1UL, ctx.InboundDecoderTable.InsertCount); + Assert.IsTrue(ctx.InboundDecoderTable.TryGetByAbsoluteIndex(0, out var name, out var value)); + Assert.AreEqual("text/plain", value); + Assert.IsFalse(string.IsNullOrEmpty(name)); + } + + [TestMethod] + public async Task TryParse_Duplicate_ReinsertsExistingEntry() + { + await using var ctx = new QpackContext(4096); + var insert = EncodeInsertLiteral("a", "1"); + Assert.IsTrue(QpackEncoderStreamReader.TryParseOneInstruction(insert, ctx, out _)); + + // Duplicate absolute index 0: 000 Index(5) → first byte = 0x00 + byte[] dup = [0x00]; + Assert.IsTrue(QpackEncoderStreamReader.TryParseOneInstruction(dup, ctx, out _)); + Assert.AreEqual(2UL, ctx.InboundDecoderTable.InsertCount); + } + + [TestMethod] + public async Task TryParse_IncompleteInstruction_ReturnsFalseWithoutConsuming() + { + await using var ctx = new QpackContext(4096); + // Insert With Literal Name prefix but truncated name length. + byte[] incomplete = [0x20, 0x05]; // 001 ...., length=5, no name bytes + + Assert.IsFalse(QpackEncoderStreamReader.TryParseOneInstruction(incomplete, ctx, out var consumed)); + Assert.AreEqual(0, consumed); + Assert.AreEqual(0UL, ctx.InboundDecoderTable.InsertCount); + } + + [TestMethod] + public async Task ProcessAsync_CarriesPendingAcrossReads_ThenApplies() + { + await using var ctx = new QpackContext(4096); + var instruction = EncodeInsertLiteral("host", "example.com"); + + // Feed one byte at a time via a stream that yields partial reads. + await using var ms = new MemoryStream(instruction); + await QpackEncoderStreamReader.ProcessAsync(ms, ctx, CancellationToken.None); + + Assert.AreEqual(1UL, ctx.InboundDecoderTable.InsertCount); + Assert.IsTrue(ctx.InboundDecoderTable.TryGetByAbsoluteIndex(0, out var name, out var value)); + Assert.AreEqual("host", name); + Assert.AreEqual("example.com", value); + } + + [TestMethod] + public async Task ProcessAsync_TruncatedInstructionAtEof_ThrowsQpackEncoderStreamError() + { + await using var ctx = new QpackContext(4096); + await using var ms = new MemoryStream([0x20, 0x05]); // incomplete insert-literal + + var ex = await Assert.ThrowsExceptionAsync( + () => QpackEncoderStreamReader.ProcessAsync(ms, ctx, CancellationToken.None)); + + Assert.AreEqual(Http3ErrorCode.QpackEncoderStreamError, ex.ErrorCode); + } + + [TestMethod] + public async Task TryParse_OutOfRangeStaticIndex_SkipsInsert() + { + await using var ctx = new QpackContext(4096); + // Prefixed int with 6-bit mask all-ones then large remainder → index beyond static table. + // First byte 0xFF (static + mask), then encode a large index remainder. + // Value for nameIndex: mask=63, we want nameIndex = 5000. + // remainder = 5000 - 63 = 4937. Encode 4937 as 7-bit chunks. + var instruction = new byte[16]; + instruction[0] = 0xFF; + var rem = 5000UL - 63; + var i = 1; + while (rem >= 0x80) + { + instruction[i++] = (byte)((rem & 0x7F) | 0x80); + rem >>= 7; + } + instruction[i++] = (byte)rem; + instruction[i++] = 0x00; // empty value literal + + Assert.IsTrue(QpackEncoderStreamReader.TryParseOneInstruction(instruction.AsSpan(0, i), ctx, out _)); + Assert.AreEqual(0UL, ctx.InboundDecoderTable.InsertCount); + } + + private static byte[] EncodeInsertLiteral(string name, string value) + { + var nameBytes = Encoding.Latin1.GetBytes(name); + var valueBytes = Encoding.Latin1.GetBytes(value); + // 001 N=0 .... : first byte 0x20, then name literal (7-bit len), value literal (7-bit len) + var buf = new byte[1 + 1 + nameBytes.Length + 1 + valueBytes.Length]; + buf[0] = 0x20; + buf[1] = (byte)nameBytes.Length; + nameBytes.CopyTo(buf.AsSpan(2)); + buf[2 + nameBytes.Length] = (byte)valueBytes.Length; + valueBytes.CopyTo(buf.AsSpan(3 + nameBytes.Length)); + return buf; + } +} From 36921c57d9c1dc755433d7af4dde72e07327553d Mon Sep 17 00:00:00 2001 From: justcoding121 Date: Sun, 2 Aug 2026 17:32:26 -0600 Subject: [PATCH 3/7] test: QuicConnectionPool reuse, invalidate, and stale-retry policy Inject IQuicConnectionFactory so pool share/invalidate/warmup/drain can be unit-tested without MsQuic. Also covers GetCacheKey and upstream-proxy rejection. Co-authored-by: Cursor --- .../Network/Quic/IQuicConnectionFactory.cs | 25 +++ .../Network/Quic/QuicConnectionFactory.cs | 4 +- .../Network/Quic/QuicConnectionPool.cs | 13 +- .../Network/Quic/QuicServerConnection.cs | 32 +++- .../QuicConnectionPoolTests.cs | 181 ++++++++++++++++++ 5 files changed, 250 insertions(+), 5 deletions(-) create mode 100644 src/Titanium.Web.Proxy/Network/Quic/IQuicConnectionFactory.cs create mode 100644 tests/Titanium.Web.Proxy.UnitTests/QuicConnectionPoolTests.cs diff --git a/src/Titanium.Web.Proxy/Network/Quic/IQuicConnectionFactory.cs b/src/Titanium.Web.Proxy/Network/Quic/IQuicConnectionFactory.cs new file mode 100644 index 00000000..2d0b69c4 --- /dev/null +++ b/src/Titanium.Web.Proxy/Network/Quic/IQuicConnectionFactory.cs @@ -0,0 +1,25 @@ +using System.Net; +using System.Net.Security; +using System.Threading; +using System.Threading.Tasks; +using Titanium.Web.Proxy.Models; + +namespace Titanium.Web.Proxy.Network.Quic; + +/// +/// Creates outbound QUIC connections to HTTP/3 origins. Extracted so +/// policy (share, invalidate, warmup) can be unit-tested +/// without MsQuic. +/// +internal interface IQuicConnectionFactory +{ + Task CreateAsync( + string connectHost, + string sniHost, + int port, + IPEndPoint? upStreamEndPoint, + IExternalProxy? upStreamProxy, + string cacheKey, + RemoteCertificateValidationCallback? remoteCertificateValidationCallback, + CancellationToken cancellationToken); +} diff --git a/src/Titanium.Web.Proxy/Network/Quic/QuicConnectionFactory.cs b/src/Titanium.Web.Proxy/Network/Quic/QuicConnectionFactory.cs index b8370869..0ff9f3a6 100644 --- a/src/Titanium.Web.Proxy/Network/Quic/QuicConnectionFactory.cs +++ b/src/Titanium.Web.Proxy/Network/Quic/QuicConnectionFactory.cs @@ -17,7 +17,7 @@ namespace Titanium.Web.Proxy.Network.Quic; /// Creates outbound objects to origin HTTP/3 servers. /// Analogous to but for QUIC. /// -internal sealed class QuicConnectionFactory +internal sealed class QuicConnectionFactory : IQuicConnectionFactory { private readonly ProxyServer _proxyServer; @@ -43,7 +43,7 @@ internal QuicConnectionFactory(ProxyServer proxyServer) /// expose a mechanism for CONNECT tunnelling or SOCKS5 UDP ASSOCIATE; the caller must catch this /// and fall back to a TCP-based bridge so proxy rules are honoured. /// - internal async Task CreateAsync( + public async Task CreateAsync( string connectHost, string sniHost, int port, diff --git a/src/Titanium.Web.Proxy/Network/Quic/QuicConnectionPool.cs b/src/Titanium.Web.Proxy/Network/Quic/QuicConnectionPool.cs index 4d572961..dcb0a4e9 100644 --- a/src/Titanium.Web.Proxy/Network/Quic/QuicConnectionPool.cs +++ b/src/Titanium.Web.Proxy/Network/Quic/QuicConnectionPool.cs @@ -54,16 +54,25 @@ internal sealed class QuicConnectionPool : IAsyncDisposable private readonly ConcurrentDictionary _pool = new(); private readonly ConcurrentDictionary _warmupsInFlight = new(); private readonly ProxyServer _proxyServer; - private readonly QuicConnectionFactory _factory; + private readonly IQuicConnectionFactory _factory; private readonly SemaphoreSlim _drainGate = new(1, 1); private readonly CancellationTokenSource _cleanupCts = new(); private readonly Task _cleanupTask; private volatile bool _draining; internal QuicConnectionPool(ProxyServer proxyServer) + : this(proxyServer, new QuicConnectionFactory(proxyServer)) + { + } + + /// + /// Test seam: inject a fake factory so pool share/invalidate/warmup policy can be exercised + /// without MsQuic. + /// + internal QuicConnectionPool(ProxyServer proxyServer, IQuicConnectionFactory factory) { _proxyServer = proxyServer; - _factory = new QuicConnectionFactory(proxyServer); + _factory = factory; // Run on the thread pool so the first sweep (which may complete synchronously if the pool // starts empty) cannot block construction. _cleanupTask = Task.Run(ClearIdleConnectionsAsync); diff --git a/src/Titanium.Web.Proxy/Network/Quic/QuicServerConnection.cs b/src/Titanium.Web.Proxy/Network/Quic/QuicServerConnection.cs index 45b57b29..37aa7e09 100644 --- a/src/Titanium.Web.Proxy/Network/Quic/QuicServerConnection.cs +++ b/src/Titanium.Web.Proxy/Network/Quic/QuicServerConnection.cs @@ -45,11 +45,37 @@ internal QuicServerConnection( NegotiatedApplicationProtocol = SslApplicationProtocol.Http3; } + /// + /// Detached instance for pool unit tests — no live . + /// + internal static QuicServerConnection CreateDetachedForTests( + ProxyServer proxyServer, string hostName, int port, string cacheKey) + { + return new QuicServerConnection(proxyServer, hostName, port, cacheKey); + } + + private QuicServerConnection(ProxyServer proxyServer, string hostName, int port, string cacheKey) + { + Connection = null; + LastAccess = DateTime.UtcNow; + ProxyServer = proxyServer; + ProxyServer.UpdateServerConnectionCount(true); + ProxyServer.UpdateHttp3ServerConnectionCount(true); + HostName = hostName; + Port = port; + CacheKey = cacheKey; + NegotiatedApplicationProtocol = SslApplicationProtocol.Http3; + } + public long Id { get; } = ConnectionId.Next(); private ProxyServer ProxyServer { get; } - internal QuicConnection Connection { get; } + /// + /// Underlying MsQuic connection. only for + /// instances used by pool unit tests. + /// + internal QuicConnection? Connection { get; } internal string HostName { get; set; } @@ -82,6 +108,7 @@ internal IPEndPoint? RemoteEndPoint { get { + if (Connection is null) return null; try { return Connection.RemoteEndPoint; @@ -105,6 +132,8 @@ internal IPEndPoint? RemoteEndPoint internal System.Threading.Tasks.ValueTask OpenRequestStreamAsync( System.Threading.CancellationToken cancellationToken) { + if (Connection is null) + throw new InvalidOperationException("Detached QuicServerConnection cannot open streams."); LastAccess = DateTime.UtcNow; return Connection.OpenOutboundStreamAsync(QuicStreamType.Bidirectional, cancellationToken); } @@ -167,6 +196,7 @@ public async ValueTask DisposeAsync() _disposed = true; ProxyServer.UpdateServerConnectionCount(false); ProxyServer.UpdateHttp3ServerConnectionCount(false); + if (Connection is null) return; try { await Connection.CloseAsync((long)Http3.Http3ErrorCode.NoError).AsTask() diff --git a/tests/Titanium.Web.Proxy.UnitTests/QuicConnectionPoolTests.cs b/tests/Titanium.Web.Proxy.UnitTests/QuicConnectionPoolTests.cs new file mode 100644 index 00000000..4d1a58bb --- /dev/null +++ b/tests/Titanium.Web.Proxy.UnitTests/QuicConnectionPoolTests.cs @@ -0,0 +1,181 @@ +using System; +using System.Net; +using System.Net.Security; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Titanium.Web.Proxy.Exceptions; +using Titanium.Web.Proxy.Models; +using Titanium.Web.Proxy.Network.Quic; + +namespace Titanium.Web.Proxy.UnitTests; + +/// +/// Unit coverage for outbound QUIC pool share / invalidate / drain / warmup policy +/// and . +/// +[TestClass] +public class QuicConnectionPoolTests +{ + [TestMethod] + public void GetCacheKey_IncludesConnectHostSniPortAndProxyCoordinates() + { + var proxy = new ExternalProxy("proxy.example", 8080); + var ep = new IPEndPoint(IPAddress.Loopback, 9); + + var key = QuicConnectionFactory.GetCacheKey("connect.example", 443, "sni.example", proxy, ep); + + StringAssert.StartsWith(key, "h3:connect.example:443:sni.example:"); + StringAssert.Contains(key, "proxy.example"); + StringAssert.Contains(key, "127.0.0.1"); + } + + [TestMethod] + public void GetCacheKey_DifferentSni_ProducesDifferentKeys() + { + var a = QuicConnectionFactory.GetCacheKey("same", 443, "a.example", null, null); + var b = QuicConnectionFactory.GetCacheKey("same", 443, "b.example", null, null); + Assert.AreNotEqual(a, b); + } + + [TestMethod] + public async Task CreateAsync_WithUpstreamProxy_ThrowsQuicProxyNotSupported() + { + using var proxy = new ProxyServer(false, false, false); + var factory = new QuicConnectionFactory(proxy); + var upstream = new ExternalProxy("proxy.example", 8080); + + await Assert.ThrowsExceptionAsync(() => + factory.CreateAsync("origin.example", "origin.example", 443, null, upstream, + "key", null, CancellationToken.None)); + } + + [TestMethod] + public async Task GetOrCreateAsync_SharesOneConnectionAcrossConcurrentAcquires() + { + using var proxy = new ProxyServer(false, false, false); + var factory = new FakeQuicFactory(proxy); + await using var pool = new QuicConnectionPool(proxy, factory); + + var t1 = pool.GetOrCreateAsync("origin.example", 443, null, null, null, CancellationToken.None); + var t2 = pool.GetOrCreateAsync("origin.example", 443, null, null, null, CancellationToken.None); + var c1 = await t1; + var c2 = await t2; + + Assert.AreSame(c1, c2); + Assert.AreEqual(1, factory.CreateCount); + Assert.AreEqual(2, c1.InFlightStreams); + + await pool.ReleaseAsync(c1); + await pool.ReleaseAsync(c2); + Assert.AreEqual(0, c1.InFlightStreams); + Assert.IsTrue(proxy.Http3WarmOrigins.IsWarm("origin.example", 443)); + } + + [TestMethod] + public async Task InvalidateAsync_ForcesNextAcquireToCreateFreshConnection() + { + using var proxy = new ProxyServer(false, false, false); + var factory = new FakeQuicFactory(proxy); + await using var pool = new QuicConnectionPool(proxy, factory); + + var first = await pool.GetOrCreateAsync("origin.example", 443, null, null, null, CancellationToken.None); + Assert.IsTrue(proxy.Http3WarmOrigins.IsWarm("origin.example", 443)); + await pool.InvalidateAsync(first); + Assert.IsFalse(proxy.Http3WarmOrigins.IsWarm("origin.example", 443), + "Invalidate must clear warm-origin mark so Auto policy does not keep routing to a dead pool entry."); + + var second = await pool.GetOrCreateAsync("origin.example", 443, null, null, null, CancellationToken.None); + + Assert.AreNotSame(first, second); + Assert.AreEqual(2, factory.CreateCount); + Assert.IsTrue(first.IsClosed); + Assert.IsTrue(proxy.Http3WarmOrigins.IsWarm("origin.example", 443), + "A successful replacement connection must remake the warm-origin mark."); + + await pool.ReleaseAsync(second); + } + + [TestMethod] + public async Task GetOrCreateAsync_AfterDrain_Throws() + { + using var proxy = new ProxyServer(false, false, false); + var factory = new FakeQuicFactory(proxy); + await using var pool = new QuicConnectionPool(proxy, factory); + + var conn = await pool.GetOrCreateAsync("origin.example", 443, null, null, null, CancellationToken.None); + await pool.ReleaseAsync(conn); + await pool.DrainAsync(); + + await Assert.ThrowsExceptionAsync(() => + pool.GetOrCreateAsync("origin.example", 443, null, null, null, CancellationToken.None).AsTask()); + } + + [TestMethod] + public async Task BeginWarmup_CreatesConnectionAndMarksOriginWarm() + { + using var proxy = new ProxyServer(false, false, false); + var factory = new FakeQuicFactory(proxy); + await using var pool = new QuicConnectionPool(proxy, factory); + + pool.BeginWarmup("connect.example", 443, "sni.example", null); + + // Warmup runs on the thread pool; wait until the origin is marked warm or timeout. + var deadline = DateTime.UtcNow.AddSeconds(3); + while (!proxy.Http3WarmOrigins.IsWarm("sni.example", 443) && DateTime.UtcNow < deadline) + await Task.Delay(20); + + Assert.IsTrue(proxy.Http3WarmOrigins.IsWarm("sni.example", 443)); + Assert.AreEqual(1, factory.CreateCount); + + // Second warmup for the same origin must be a no-op once warm. + pool.BeginWarmup("connect.example", 443, "sni.example", null); + await Task.Delay(50); + Assert.AreEqual(1, factory.CreateCount); + } + + [TestMethod] + public async Task TryAcquireStream_OnRetiredConnection_ReturnsFalse() + { + using var proxy = new ProxyServer(false, false, false); + var conn = QuicServerConnection.CreateDetachedForTests(proxy, "h", 443, "key"); + Assert.IsTrue(conn.TryAcquireStream()); + Assert.IsTrue(conn.TryScheduleDisposal()); + Assert.IsFalse(conn.TryAcquireStream()); + Assert.AreEqual(1, conn.InFlightStreams); + conn.ReleaseStream(); + await conn.DisposeAsync(); + } + + [TestMethod] + public void MaxStaleConnectionRetries_IsAtLeastOne() + { + Assert.IsTrue(QuicConnectionPool.MaxStaleConnectionRetries >= 1); + } + + private sealed class FakeQuicFactory : IQuicConnectionFactory + { + private readonly ProxyServer _proxy; + private int _createCount; + + public FakeQuicFactory(ProxyServer proxy) => _proxy = proxy; + + public int CreateCount => Volatile.Read(ref _createCount); + + public Task CreateAsync( + string connectHost, + string sniHost, + int port, + IPEndPoint? upStreamEndPoint, + IExternalProxy? upStreamProxy, + string cacheKey, + RemoteCertificateValidationCallback? remoteCertificateValidationCallback, + CancellationToken cancellationToken) + { + Interlocked.Increment(ref _createCount); + // Simulate a slow handshake so concurrent GetOrCreate callers exercise the creation gate. + return Task.FromResult( + QuicServerConnection.CreateDetachedForTests(_proxy, sniHost, port, cacheKey)); + } + } +} From fd2627a6c9935e54622fbf85a4f2460bf4a61636 Mon Sep 17 00:00:00 2001 From: justcoding121 Date: Sun, 2 Aug 2026 17:39:56 -0600 Subject: [PATCH 4/7] test: HTTP/3 transparent e2e harness and fix request/body bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add QuicHttp3 origin/client helpers and gated transparent proxy integration tests. Fixes: populate SessionEventArgs.Request (was discarded), honour ServerCertificateValidationCallback on H3→H3 connect, and send synthetic Ok() bodies when IsBodyRead is unset. Co-authored-by: Cursor --- .../Http3/Http3OriginBridge.cs | 7 +- .../Http3/Http3RequestStream.cs | 30 +- .../Helpers/QuicHttp3Helpers.cs | 353 ++++++++++++++++++ .../Http3OriginDirectTests.cs | 38 ++ .../Http3TransparentTests.cs | 274 ++++++++++++++ 5 files changed, 688 insertions(+), 14 deletions(-) create mode 100644 tests/Titanium.Web.Proxy.IntegrationTests/Helpers/QuicHttp3Helpers.cs create mode 100644 tests/Titanium.Web.Proxy.IntegrationTests/Http3OriginDirectTests.cs create mode 100644 tests/Titanium.Web.Proxy.IntegrationTests/Http3TransparentTests.cs diff --git a/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.cs b/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.cs index cb91cca7..d4d4b447 100644 --- a/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.cs +++ b/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.cs @@ -169,9 +169,14 @@ private static async Task ForwardOverQuicAsync( { try { + // Pass the session so ServerCertificateValidationCallback is honoured. The factory's + // default path supplies sessionArgs: null, which skips the user callback and rejects + // any chain that is not already trusted by the OS (breaking MITM-test and custom-CA + // deployments for every H3→H3 origin connect). quicConn = await server.QuicConnectionPool.GetOrCreateAsync( connectHost, port, upStreamEndPoint, upstreamProxy, - null /* default cert validation */, + (sender, certificate, chain, errors) => + server.ValidateServerCertificate(sender, sessionArgs, certificate, chain, errors), cancellationToken, sniHost: sniHost); diff --git a/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs b/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs index b623afe1..bf6591b9 100644 --- a/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs +++ b/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs @@ -99,17 +99,10 @@ public static async Task HandleAsync( (System.Net.IPEndPoint)connection.LocalEndPoint, (System.Net.IPEndPoint)connection.RemoteEndPoint); - var request = new Request(); - request.Method = method; - var url = BuildUrl(scheme ?? "https", authority, path ?? "/"); - request.RequestUri = new Uri(url); - request.HttpVersion = HttpHeader.Version30; - request.IsHttps = string.Equals(scheme, "https", StringComparison.OrdinalIgnoreCase); - - foreach (var (name, value) in regularHeaders) - request.Headers.AddHeader(new HttpHeader(name, value)); - - // 4. Create SessionEventArgs using a null-backed HttpClientStream. + // 4. Create SessionEventArgs using a null-backed HttpClientStream, then populate + // the session's Request. SessionEventArgs always constructs its own Request; a + // discarded local Request previously left Host/URI empty so H3→origin forwarding + // failed with Invalid URI: 'http://'. cts = new CancellationTokenSource(); linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken); @@ -119,6 +112,16 @@ public static async Task HandleAsync( sessionArgs = new SessionEventArgs(server, endPoint, nullHttpClientStream, null, cts); + var request = sessionArgs.HttpClient.Request; + request.Method = method; + var url = BuildUrl(scheme ?? "https", authority, path ?? "/"); + request.RequestUri = new Uri(url); + request.HttpVersion = HttpHeader.Version30; + request.IsHttps = string.Equals(scheme, "https", StringComparison.OrdinalIgnoreCase); + + foreach (var (name, value) in regularHeaders) + request.Headers.AddHeader(new HttpHeader(name, value)); + // Seed per-connection overrides from the auth event. // CustomUpStreamProxy is the typed proxy field read by the bridge; UserData is // intentionally left null so the public API is not polluted with internal state. @@ -402,8 +405,9 @@ private static async Task SendResponseAsync(QuicStream stream, Response response var qpackHeaders = QpackEncoder.Encode(headers, qpackContext); await Http3Frame.WriteAsync(stream, Http3FrameType.Headers, qpackHeaders, ct); - // Send body if present. - var body = response.IsBodyRead ? response.Body : null; + // Send body if present. Ok()/Respond assign Body without setting IsBodyRead (H1 uses + // BodyAvailable); requiring IsBodyRead alone dropped every synthetic H3 response body. + var body = response.BodyAvailable || response.IsBodyRead ? response.Body : null; if (body is { Length: > 0 }) await Http3Frame.WriteAsync(stream, Http3FrameType.Data, body, ct); diff --git a/tests/Titanium.Web.Proxy.IntegrationTests/Helpers/QuicHttp3Helpers.cs b/tests/Titanium.Web.Proxy.IntegrationTests/Helpers/QuicHttp3Helpers.cs new file mode 100644 index 00000000..f9375c40 --- /dev/null +++ b/tests/Titanium.Web.Proxy.IntegrationTests/Helpers/QuicHttp3Helpers.cs @@ -0,0 +1,353 @@ +#pragma warning disable CA1416 +#pragma warning disable TWP001 + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Quic; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Titanium.Web.Proxy.Http3; +using Titanium.Web.Proxy.Http3.Qpack; +using Titanium.Web.Proxy.IntegrationTests.Setup; + +namespace Titanium.Web.Proxy.IntegrationTests.Helpers; + +/// +/// Minimal HTTP/3 origin over for end-to-end proxy tests. +/// +internal sealed class QuicHttp3OriginServer : IAsyncDisposable +{ + private readonly X509Certificate2 certificate; + private readonly QuicListener listener; + private readonly CancellationTokenSource cts = new(); + private Func> handler = + _ => Task.FromResult(new QuicHttp3Response(200, "ok")); + private int acceptedConnectionCount; + + public QuicHttp3OriginServer(X509Certificate2 certificate) + { + this.certificate = certificate; + var options = new QuicListenerOptions + { + // Dual-stack: QuicConnectionFactory connects via DnsEndPoint("localhost"), which may + // resolve to ::1 first. An IPv4-only Loopback listener never Accept()s those handshakes + // and surfaces as a misleading ALPN failure on the client. + ListenEndPoint = new IPEndPoint(IPAddress.IPv6Any, 0), + ApplicationProtocols = new List { SslApplicationProtocol.Http3 }, + ConnectionOptionsCallback = (_, _, _) => ValueTask.FromResult(new QuicServerConnectionOptions + { + DefaultStreamErrorCode = (long)Http3ErrorCode.RequestCancelled, + DefaultCloseErrorCode = (long)Http3ErrorCode.NoError, + IdleTimeout = TimeSpan.FromSeconds(30), + MaxInboundBidirectionalStreams = 100, + MaxInboundUnidirectionalStreams = 3, + ServerAuthenticationOptions = new SslServerAuthenticationOptions + { + ServerCertificate = this.certificate, + ApplicationProtocols = new List { SslApplicationProtocol.Http3 } + } + }) + }; + + listener = QuicListener.ListenAsync(options).AsTask().GetAwaiter().GetResult(); + _ = AcceptLoopAsync(); + } + + public int Port => listener.LocalEndPoint.Port; + + public int AcceptedConnectionCount => Volatile.Read(ref acceptedConnectionCount); + + public void HandleRequest(Func> requestHandler) + => handler = requestHandler; + + private async Task AcceptLoopAsync() + { + while (!cts.IsCancellationRequested) + { + QuicConnection connection; + try + { + connection = await listener.AcceptConnectionAsync(cts.Token); + } + catch + { + return; + } + + Interlocked.Increment(ref acceptedConnectionCount); + _ = Task.Run(() => HandleConnectionAsync(connection)); + } + } + + private async Task HandleConnectionAsync(QuicConnection connection) + { + await using (connection) + { + try + { + // Server control stream + SETTINGS (required before serving requests). + await using var control = await connection.OpenOutboundStreamAsync( + QuicStreamType.Unidirectional, cts.Token); + await control.WriteAsync(new byte[] { (byte)Http3StreamType.Control }, cts.Token); + var settings = new Http3Settings(); + settings.SetQpackMaxTableCapacity(0); + settings.SetQpackBlockedStreams(0); + await Http3Frame.WriteAsync(control, Http3FrameType.Settings, settings.Serialize(), cts.Token); + + while (!cts.IsCancellationRequested) + { + var stream = await connection.AcceptInboundStreamAsync(cts.Token); + if (stream.Type == QuicStreamType.Unidirectional) + { + _ = Task.Run(async () => + { + await using (stream) + { + // Drain client control / QPACK streams; protocol requires SETTINGS first + // on the client control stream, but this origin does not depend on it. + var buf = new byte[4096]; + while (await stream.ReadAsync(buf, cts.Token) > 0) { } + } + }); + continue; + } + + _ = Task.Run(() => HandleRequestStreamAsync(stream)); + } + } + catch (OperationCanceledException) { } + catch (QuicException) { } + } + } + + private async Task HandleRequestStreamAsync(QuicStream stream) + { + await using (stream) + { + try + { + var headersFrame = await Http3Frame.ReadAsync(stream, maxPayloadBytes: 64 * 1024, cts.Token); + if (headersFrame is null || headersFrame.Type != Http3FrameType.Headers) + return; + + var decoded = QpackDecoder.Decode(headersFrame.Payload.Span); + string method = "GET", path = "/", authority = "localhost"; + foreach (var (name, value) in decoded) + { + switch (name) + { + case ":method": method = value; break; + case ":path": path = value; break; + case ":authority": authority = value; break; + } + } + + var body = new List(); + while (true) + { + var frame = await Http3Frame.ReadAsync(stream, maxPayloadBytes: 0, cts.Token); + if (frame is null) break; + if (frame.Type == Http3FrameType.Data) + body.AddRange(frame.Payload.ToArray()); + else if (frame.Type == Http3FrameType.Headers) + break; + } + + var response = await handler(new QuicHttp3Request(method, path, authority, body.ToArray())); + var responseHeaders = QpackEncoder.Encode(new List<(string, string)> + { + (":status", response.StatusCode.ToString()), + ("content-type", "text/plain") + }); + await Http3Frame.WriteAsync(stream, Http3FrameType.Headers, responseHeaders, cts.Token); + if (response.Body is { Length: > 0 }) + await Http3Frame.WriteAsync(stream, Http3FrameType.Data, response.Body, cts.Token); + stream.CompleteWrites(); + } + catch (OperationCanceledException) { } + catch (QuicException) { } + catch (Http3ConnectionException) { } + catch (Http3StreamException) { } + } + } + + public async ValueTask DisposeAsync() + { + cts.Cancel(); + await listener.DisposeAsync(); + cts.Dispose(); + certificate.Dispose(); + } +} + +/// +/// Minimal HTTP/3 client over for transparent-proxy tests. +/// +internal sealed class QuicHttp3Client : IAsyncDisposable +{ + private readonly QuicConnection connection; + private bool controlOpened; + + private QuicHttp3Client(QuicConnection connection) => this.connection = connection; + + public static async Task ConnectAsync( + IPEndPoint remoteEndPoint, + string sniHost, + RemoteCertificateValidationCallback? validationCallback = null, + CancellationToken cancellationToken = default) + { + var options = new QuicClientConnectionOptions + { + RemoteEndPoint = remoteEndPoint, + DefaultStreamErrorCode = (long)Http3ErrorCode.RequestCancelled, + DefaultCloseErrorCode = (long)Http3ErrorCode.NoError, + MaxInboundBidirectionalStreams = 0, + MaxInboundUnidirectionalStreams = 3, + ClientAuthenticationOptions = new SslClientAuthenticationOptions + { + ApplicationProtocols = new List { SslApplicationProtocol.Http3 }, + TargetHost = sniHost, + RemoteCertificateValidationCallback = validationCallback + ?? ((_, cert, _, errors) => + cert != null && TestCertificateAuthority.Validate(cert, errors)) + } + }; + + var connection = await QuicConnection.ConnectAsync(options, cancellationToken); + var client = new QuicHttp3Client(connection); + client.StartAcceptingPeerStreams(); + await client.OpenControlStreamAsync(cancellationToken); + return client; + } + + private QuicStream? controlStream; + private readonly CancellationTokenSource lifetimeCts = new(); + + private void StartAcceptingPeerStreams() + { + // Drain proxy→client control / QPACK uni streams so MsQuic flow control does not stall. + _ = Task.Run(async () => + { + try + { + while (!lifetimeCts.IsCancellationRequested) + { + var stream = await connection.AcceptInboundStreamAsync(lifetimeCts.Token); + _ = Task.Run(async () => + { + await using (stream) + { + var buf = new byte[4096]; + while (await stream.ReadAsync(buf, lifetimeCts.Token) > 0) { } + } + }); + } + } + catch + { + // Connection closed. + } + }); + } + + private async Task OpenControlStreamAsync(CancellationToken cancellationToken) + { + if (controlOpened) return; + controlStream = await connection.OpenOutboundStreamAsync( + QuicStreamType.Unidirectional, cancellationToken); + await controlStream.WriteAsync(new byte[] { (byte)Http3StreamType.Control }, cancellationToken); + var settings = new Http3Settings(); + settings.SetQpackMaxTableCapacity(0); + settings.SetQpackBlockedStreams(0); + await Http3Frame.WriteAsync(controlStream, Http3FrameType.Settings, settings.Serialize(), cancellationToken); + controlOpened = true; + } + + public async Task SendAsync( + string method, + string authority, + string path, + byte[]? body = null, + CancellationToken cancellationToken = default) + { + await using var stream = await connection.OpenOutboundStreamAsync( + QuicStreamType.Bidirectional, cancellationToken); + + var headers = new List<(string, string)> + { + (":method", method), + (":scheme", "https"), + (":authority", authority), + (":path", path) + }; + if (body is { Length: > 0 }) + headers.Add(("content-length", body.Length.ToString())); + + await Http3Frame.WriteAsync(stream, Http3FrameType.Headers, QpackEncoder.Encode(headers), cancellationToken); + if (body is { Length: > 0 }) + await Http3Frame.WriteAsync(stream, Http3FrameType.Data, body, cancellationToken); + stream.CompleteWrites(); + + var headersFrame = await Http3Frame.ReadAsync(stream, maxPayloadBytes: 64 * 1024, cancellationToken); + if (headersFrame is null || headersFrame.Type != Http3FrameType.Headers) + throw new InvalidOperationException("Expected response HEADERS frame."); + + var decoded = QpackDecoder.Decode(headersFrame.Payload.Span); + var status = 0; + foreach (var (name, value) in decoded) + { + if (name == ":status" && int.TryParse(value, out var code)) + status = code; + } + + var responseBody = new List(); + while (true) + { + var frame = await Http3Frame.ReadAsync(stream, maxPayloadBytes: 0, cancellationToken); + if (frame is null) break; + if (frame.Type == Http3FrameType.Data) + responseBody.AddRange(frame.Payload.ToArray()); + else if (frame.Type == Http3FrameType.Headers) + break; + } + + return new QuicHttp3Response(status, Encoding.UTF8.GetString(responseBody.ToArray()), responseBody.ToArray()); + } + + public async ValueTask DisposeAsync() + { + lifetimeCts.Cancel(); + if (controlStream != null) + await controlStream.DisposeAsync(); + await connection.DisposeAsync(); + lifetimeCts.Dispose(); + } +} + +internal readonly record struct QuicHttp3Request(string Method, string Path, string Authority, byte[] Body); + +internal sealed class QuicHttp3Response +{ + public QuicHttp3Response(int statusCode, string textBody) + : this(statusCode, textBody, Encoding.UTF8.GetBytes(textBody)) + { + } + + public QuicHttp3Response(int statusCode, string textBody, byte[] body) + { + StatusCode = statusCode; + TextBody = textBody; + Body = body; + } + + public int StatusCode { get; } + public string TextBody { get; } + public byte[] Body { get; } +} + +#pragma warning restore TWP001 +#pragma warning restore CA1416 diff --git a/tests/Titanium.Web.Proxy.IntegrationTests/Http3OriginDirectTests.cs b/tests/Titanium.Web.Proxy.IntegrationTests/Http3OriginDirectTests.cs new file mode 100644 index 00000000..5fceebe4 --- /dev/null +++ b/tests/Titanium.Web.Proxy.IntegrationTests/Http3OriginDirectTests.cs @@ -0,0 +1,38 @@ +#pragma warning disable CA1416 +#pragma warning disable TWP001 + +using System.Net; +using System.Net.Quic; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Titanium.Web.Proxy.IntegrationTests.Helpers; +using Titanium.Web.Proxy.IntegrationTests.Setup; + +namespace Titanium.Web.Proxy.IntegrationTests; + +/// +/// Sanity-check the H3 test origin without the proxy in the path. +/// +[TestClass] +public class Http3OriginDirectTests +{ + [TestMethod] + public async Task Client_To_Origin_DirectRoundTrip() + { + if (!QuicListener.IsSupported || !QuicConnection.IsSupported) + Assert.Inconclusive("MsQuic not supported."); + + await using var origin = new QuicHttp3OriginServer(TestCertificateAuthority.ServerCertificate); + origin.HandleRequest(req => Task.FromResult(new QuicHttp3Response(200, "direct-" + req.Path))); + + await using var client = await QuicHttp3Client.ConnectAsync( + new IPEndPoint(IPAddress.Loopback, origin.Port), "localhost"); + + var response = await client.SendAsync("GET", $"localhost:{origin.Port}", "/x"); + Assert.AreEqual(200, response.StatusCode); + Assert.AreEqual("direct-/x", response.TextBody); + } +} + +#pragma warning restore TWP001 +#pragma warning restore CA1416 diff --git a/tests/Titanium.Web.Proxy.IntegrationTests/Http3TransparentTests.cs b/tests/Titanium.Web.Proxy.IntegrationTests/Http3TransparentTests.cs new file mode 100644 index 00000000..095118cb --- /dev/null +++ b/tests/Titanium.Web.Proxy.IntegrationTests/Http3TransparentTests.cs @@ -0,0 +1,274 @@ +#pragma warning disable CA1416 +#pragma warning disable TWP001 + +using System; +using System.Net; +using System.Net.Quic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Titanium.Web.Proxy.IntegrationTests.Helpers; +using Titanium.Web.Proxy.IntegrationTests.Setup; +using Titanium.Web.Proxy.Models; + +namespace Titanium.Web.Proxy.IntegrationTests; + +/// +/// End-to-end HTTP/3 transparent proxy tests (client QUIC → proxy → H3 origin). +/// Gated on so machines without MsQuic skip cleanly. +/// +[TestClass] +public class Http3TransparentTests +{ + public TestContext TestContext { get; set; } + + private static void RequireQuic() + { + if (!QuicListener.IsSupported || !QuicConnection.IsSupported) + Assert.Inconclusive("MsQuic / System.Net.Quic is not supported on this platform."); + } + + private static ProxyServer CreateHttp3Proxy(TransparentQuicProxyEndPoint quicEndPoint) + { + var proxy = new ProxyServer(false, false, false) + { + EnableHttp3 = true, + EnableHttpsSvcbDnsDiscovery = false + }; + proxy.CertificateManager.RootCertificateName = TestCertificateAuthority.RootCertificateName; + proxy.CertificateManager.RootCertificate = TestCertificateAuthority.RootCertificate; + proxy.CertificateManager.SaveFakeCertificates = false; + proxy.ServerCertificateValidationCallback += (_, args) => + { + args.IsValid = TestCertificateAuthority.Validate(args.Certificate, args.SslPolicyErrors); + return Task.CompletedTask; + }; + + quicEndPoint.BeforeQuicAuthenticate += async (_, args) => + { + // Force H3→H3 so the request hits QuicConnectionPool + Http3OriginBridge. + args.UpstreamHttpProtocol = UpstreamHttpProtocol.Http3; + await Task.CompletedTask; + }; + + proxy.AddEndPoint(quicEndPoint); + proxy.Start(); + return proxy; + } + + [TestMethod] + public async Task Get_RoundTrip_ReturnsOriginBody() + { + RequireQuic(); + + await using var origin = new QuicHttp3OriginServer(TestCertificateAuthority.ServerCertificate); + origin.HandleRequest(req => + { + Assert.AreEqual("GET", req.Method); + Assert.AreEqual("/hello", req.Path); + return Task.FromResult(new QuicHttp3Response(200, "h3-ok")); + }); + + var quicEp = new TransparentQuicProxyEndPoint(IPAddress.Loopback, 0) + { + ForwardHost = "localhost", + ForwardPort = origin.Port + }; + + using var proxy = CreateHttp3Proxy(quicEp); + await using var client = await QuicHttp3Client.ConnectAsync( + new IPEndPoint(IPAddress.Loopback, quicEp.Port), "localhost"); + + var response = await client.SendAsync("GET", $"localhost:{origin.Port}", "/hello"); + + Assert.AreEqual(200, response.StatusCode, $"body={response.TextBody}; originAccepts={origin.AcceptedConnectionCount}"); + Assert.AreEqual("h3-ok", response.TextBody); + Assert.IsTrue(origin.AcceptedConnectionCount >= 1); + } + + [TestMethod] + public async Task Post_BodyIntegrity_RoundTrips() + { + RequireQuic(); + + var payload = Encoding.UTF8.GetBytes("post-body-12345"); + byte[]? seen = null; + + await using var origin = new QuicHttp3OriginServer(TestCertificateAuthority.ServerCertificate); + origin.HandleRequest(req => + { + seen = req.Body; + return Task.FromResult(new QuicHttp3Response(200, $"len={req.Body.Length}")); + }); + + var quicEp = new TransparentQuicProxyEndPoint(IPAddress.Loopback, 0) + { + ForwardHost = "localhost", + ForwardPort = origin.Port + }; + + using var proxy = CreateHttp3Proxy(quicEp); + await using var client = await QuicHttp3Client.ConnectAsync( + new IPEndPoint(IPAddress.Loopback, quicEp.Port), "localhost"); + + var response = await client.SendAsync("POST", $"localhost:{origin.Port}", "/echo", payload); + + Assert.AreEqual(200, response.StatusCode); + Assert.AreEqual($"len={payload.Length}", response.TextBody); + CollectionAssert.AreEqual(payload, seen); + } + + [TestMethod] + public async Task BeforeRequest_SyntheticOk_DoesNotHitOrigin() + { + RequireQuic(); + + await using var origin = new QuicHttp3OriginServer(TestCertificateAuthority.ServerCertificate); + var originHits = 0; + origin.HandleRequest(_ => + { + Interlocked.Increment(ref originHits); + return Task.FromResult(new QuicHttp3Response(200, "should-not-run")); + }); + + var quicEp = new TransparentQuicProxyEndPoint(IPAddress.Loopback, 0) + { + ForwardHost = "localhost", + ForwardPort = origin.Port + }; + + using var proxy = CreateHttp3Proxy(quicEp); + proxy.BeforeRequest += (_, args) => + { + args.Ok("synthetic"); + return Task.CompletedTask; + }; + + await using var client = await QuicHttp3Client.ConnectAsync( + new IPEndPoint(IPAddress.Loopback, quicEp.Port), "localhost"); + + var response = await client.SendAsync("GET", $"localhost:{origin.Port}", "/synth"); + + Assert.AreEqual(200, response.StatusCode); + Assert.AreEqual("synthetic", response.TextBody); + Assert.AreEqual(0, originHits); + } + + [TestMethod] + public async Task ConcurrentStreams_ShareOneClientConnection() + { + RequireQuic(); + + await using var origin = new QuicHttp3OriginServer(TestCertificateAuthority.ServerCertificate); + origin.HandleRequest(req => Task.FromResult(new QuicHttp3Response(200, req.Path))); + + var quicEp = new TransparentQuicProxyEndPoint(IPAddress.Loopback, 0) + { + ForwardHost = "localhost", + ForwardPort = origin.Port + }; + + using var proxy = CreateHttp3Proxy(quicEp); + await using var client = await QuicHttp3Client.ConnectAsync( + new IPEndPoint(IPAddress.Loopback, quicEp.Port), "localhost"); + + var authority = $"localhost:{origin.Port}"; + var tasks = new[] + { + client.SendAsync("GET", authority, "/a"), + client.SendAsync("GET", authority, "/b"), + client.SendAsync("GET", authority, "/c") + }; + var results = await Task.WhenAll(tasks); + + Assert.AreEqual("/a", results[0].TextBody); + Assert.AreEqual("/b", results[1].TextBody); + Assert.AreEqual("/c", results[2].TextBody); + } + + [TestMethod] + public async Task BeforeQuicAuthenticate_Reject_PreventsHandshakeCompletion() + { + RequireQuic(); + + await using var origin = new QuicHttp3OriginServer(TestCertificateAuthority.ServerCertificate); + var quicEp = new TransparentQuicProxyEndPoint(IPAddress.Loopback, 0) + { + ForwardHost = "localhost", + ForwardPort = origin.Port + }; + quicEp.BeforeQuicAuthenticate += (_, args) => + { + args.Reject(); + return Task.CompletedTask; + }; + + using var proxy = new ProxyServer(false, false, false) { EnableHttp3 = true }; + proxy.CertificateManager.RootCertificateName = TestCertificateAuthority.RootCertificateName; + proxy.CertificateManager.RootCertificate = TestCertificateAuthority.RootCertificate; + proxy.CertificateManager.SaveFakeCertificates = false; + proxy.AddEndPoint(quicEp); + proxy.Start(); + + // Reject cancels the handshake; MsQuic surfaces this as AuthenticationException or QuicException. + try + { + await using var client = await QuicHttp3Client.ConnectAsync( + new IPEndPoint(IPAddress.Loopback, quicEp.Port), "localhost"); + Assert.Fail("Expected handshake to fail after Reject()."); + } + catch (Exception ex) when (ex is QuicException or System.Security.Authentication.AuthenticationException) + { + // expected + } + } + + [TestMethod] + public async Task StopAsync_DrainsInFlightHttp3Session() + { + RequireQuic(); + + var releaseOrigin = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var origin = new QuicHttp3OriginServer(TestCertificateAuthority.ServerCertificate); + origin.HandleRequest(async _ => + { + await releaseOrigin.Task.WaitAsync(TimeSpan.FromSeconds(10)); + return new QuicHttp3Response(200, "drained"); + }); + + var quicEp = new TransparentQuicProxyEndPoint(IPAddress.Loopback, 0) + { + ForwardHost = "localhost", + ForwardPort = origin.Port + }; + + var proxy = CreateHttp3Proxy(quicEp); + try + { + await using var client = await QuicHttp3Client.ConnectAsync( + new IPEndPoint(IPAddress.Loopback, quicEp.Port), "localhost"); + + var responseTask = client.SendAsync("GET", $"localhost:{origin.Port}", "/slow"); + // Give the request a moment to enter the proxy accept/path. + await Task.Delay(200); + Assert.IsTrue(proxy.Http3ClientConnectionCount >= 1); + + releaseOrigin.TrySetResult(); + var response = await responseTask.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.AreEqual("drained", response.TextBody); + + await proxy.StopAsync(); + Assert.AreEqual(0, proxy.Http3ClientConnectionCount); + } + finally + { + releaseOrigin.TrySetResult(); + proxy.Dispose(); + } + } +} + +#pragma warning restore TWP001 +#pragma warning restore CA1416 From af30a073cd65e3af518af932dcbc1c8ebb5fda4a Mon Sep 17 00:00:00 2001 From: justcoding121 Date: Sun, 2 Aug 2026 17:44:53 -0600 Subject: [PATCH 5/7] test: H1/H2/H3 bridge acceptance and fallback paths Add cross-version HTTP/3 bridge integration tests and fix H1 body buffering before Locked, H3 origin-form URI storage, TCP fallback BodyAvailable/Locked response reads. Co-authored-by: Cursor --- .../Handlers/RequestHandler.cs | 13 +- .../Http3/Http3OriginBridge.cs | 4 +- .../Http3/Http3RequestStream.cs | 19 +- .../Http3BridgeTests.cs | 265 ++++++++++++++++++ 4 files changed, 291 insertions(+), 10 deletions(-) create mode 100644 tests/Titanium.Web.Proxy.IntegrationTests/Http3BridgeTests.cs diff --git a/src/Titanium.Web.Proxy/Handlers/RequestHandler.cs b/src/Titanium.Web.Proxy/Handlers/RequestHandler.cs index 0eb7f9ac..2f83934c 100644 --- a/src/Titanium.Web.Proxy/Handlers/RequestHandler.cs +++ b/src/Titanium.Web.Proxy/Handlers/RequestHandler.cs @@ -419,14 +419,13 @@ private async Task HandleHttpSessionRequest(SessionEventArgs args, TcpServerConnection? serverConnection, SslApplicationProtocol sslApplicationProtocol, CancellationToken cancellationToken, CancellationTokenSource cancellationTokenSource) { - args.HttpClient.Request.Locked = true; - // do not cache server connections for WebSockets var noCache = args.HttpClient.Request.UpgradeToWebSocket; if (noCache) serverConnection = null; // H1.1 client → H3 origin bridge: resolve route from cache, warming SVCB in the background. + // Body must be buffered before Locked=true — GetRequestBody throws once the request is locked. if (!args.HttpClient.Request.UpgradeToWebSocket) { var reqHost = args.HttpClient.Request.RequestUri?.Host ?? string.Empty; @@ -438,6 +437,14 @@ private async Task HandleHttpSessionRequest(SessionEventArgs args, if (h3Route.UseH3) { + // Buffer the client request body before leaving the H1 pipeline. Without this, the + // body remains unread on the client stream (corrupting keep-alive reuse) and + // Http3OriginBridge forwards an empty Body to the H3 origin. + if (args.HttpClient.Request.HasBody && !args.HttpClient.Request.IsBodyRead) + await args.GetRequestBody(cancellationToken); + + args.HttpClient.Request.Locked = true; + await Http3.Http3OriginBridge.ForwardAsync(args, this, h3Route, logger, cancellationToken); // Http3OriginBridge only fetches/buffers the origin response into args.HttpClient.Response - @@ -512,6 +519,8 @@ await bodyWriter.CompleteAsync( } } + args.HttpClient.Request.Locked = true; + // a connection generator task with captured parameters via closure. var generator = () => TcpConnectionFactory.GetServerConnection(this, diff --git a/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.cs b/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.cs index d4d4b447..48c31e52 100644 --- a/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.cs +++ b/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.cs @@ -504,7 +504,9 @@ private static async Task ForwardOverTcpAsync( // CompressBodyAndUpdateContentLength() assumes the opposite (decompressed Body, to be // compressed for the wire) and would double-compress it here, corrupting the payload sent // to the TCP-fallback origin. Forward the bytes as-is and only fix up Content-Length. - body = request.IsBodyRead ? request.Body : null; + // Use BodyAvailable: IsBodyRead is true for GET with an empty body, and Body throws + // BodyNotFoundException when HasBody is false. + body = request.BodyAvailable ? request.Body : null; request.UpdateContentLength(); } diff --git a/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs b/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs index bf6591b9..469774b0 100644 --- a/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs +++ b/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs @@ -114,8 +114,14 @@ public static async Task HandleAsync( var request = sessionArgs.HttpClient.Request; request.Method = method; - var url = BuildUrl(scheme ?? "https", authority, path ?? "/"); - request.RequestUri = new Uri(url); + // Mirror Http2Helper: keep :authority and :path separate (origin-form RequestUriString8). + // Storing an absolute URL here made transparent H3→H1 SendRequest write absolute-form + // request targets ("GET https://host/path HTTP/1.1"), which Kestrel rejects with 400. + var normalizedPath = path ?? "/"; + if (!normalizedPath.StartsWith('/')) + normalizedPath = "/" + normalizedPath; + request.Authority = (ByteString)authority; + request.RequestUriString8 = (ByteString)normalizedPath; request.HttpVersion = HttpHeader.Version30; request.IsHttps = string.Equals(scheme, "https", StringComparison.OrdinalIgnoreCase); @@ -178,6 +184,10 @@ public static async Task HandleAsync( } else { + // Lock after BeforeRequest so GetResponseBody (TCP fallback) and API contracts + // agree the request has been committed to the origin pipeline. + sessionArgs.HttpClient.Request.Locked = true; + // 7. Forward to origin using the appropriate protocol bridge (H3→H3, H3→H2, or H3→H1.1). // Pass a relay callback so that 1xx interim responses are forwarded to the client before // the final response arrives. @@ -442,10 +452,5 @@ private static (string? Method, string? Scheme, string? Authority, string? Path, return (method, scheme, authority, path, regular); } - private static string BuildUrl(string scheme, string authority, string path) - { - if (!path.StartsWith('/')) path = "/" + path; - return $"{scheme}://{authority}{path}"; - } } #pragma warning restore CA1416 diff --git a/tests/Titanium.Web.Proxy.IntegrationTests/Http3BridgeTests.cs b/tests/Titanium.Web.Proxy.IntegrationTests/Http3BridgeTests.cs new file mode 100644 index 00000000..b07849a3 --- /dev/null +++ b/tests/Titanium.Web.Proxy.IntegrationTests/Http3BridgeTests.cs @@ -0,0 +1,265 @@ +#pragma warning disable CA1416 +#pragma warning disable TWP001 + +using System; +using System.Net; +using System.Net.Http; +using System.Net.Quic; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Titanium.Web.Proxy.IntegrationTests.Helpers; +using Titanium.Web.Proxy.IntegrationTests.Setup; +using Titanium.Web.Proxy.Models; + +namespace Titanium.Web.Proxy.IntegrationTests; + +/// +/// Cross-version HTTP/3 bridge acceptance tests (H1↔H3, H3→H1). +/// +[TestClass] +public class Http3BridgeTests +{ + private static void RequireQuic() + { + if (!QuicListener.IsSupported || !QuicConnection.IsSupported) + Assert.Inconclusive("MsQuic / System.Net.Quic is not supported on this platform."); + } + + [TestMethod] + public async Task Http11Client_ForcedHttp3Origin_DeliversResponse() + { + RequireQuic(); + + await using var origin = new QuicHttp3OriginServer(TestCertificateAuthority.ServerCertificate); + origin.HandleRequest(req => + { + Assert.AreEqual("GET", req.Method); + return Task.FromResult(new QuicHttp3Response(200, "h1-to-h3")); + }); + + using var testSuite = new TestSuite(); + var proxy = testSuite.GetProxy(); + proxy.EnableHttp3 = true; + proxy.EnableHttpsSvcbDnsDiscovery = false; + proxy.BeforeRequest += (_, args) => + { + args.UpstreamHttpProtocol = UpstreamHttpProtocol.Http3; + return Task.CompletedTask; + }; + + using var client = testSuite.GetClient(proxy); + var response = await client.GetAsync($"https://localhost:{origin.Port}/via-h3"); + var body = await response.Content.ReadAsStringAsync(); + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, body); + Assert.AreEqual("h1-to-h3", body); + Assert.AreEqual(new Version(1, 1), response.Version); + Assert.IsTrue(origin.AcceptedConnectionCount >= 1); + } + + [TestMethod] + public async Task Http11Client_ForcedHttp3Origin_PostBodyRoundTrips() + { + RequireQuic(); + + var payload = Encoding.UTF8.GetBytes("bridge-post-body"); + byte[]? seen = null; + + await using var origin = new QuicHttp3OriginServer(TestCertificateAuthority.ServerCertificate); + origin.HandleRequest(req => + { + seen = req.Body; + return Task.FromResult(new QuicHttp3Response(200, $"got={req.Body.Length}")); + }); + + using var testSuite = new TestSuite(); + var proxy = testSuite.GetProxy(); + proxy.EnableHttp3 = true; + proxy.EnableHttpsSvcbDnsDiscovery = false; + proxy.BeforeRequest += (_, args) => + { + args.UpstreamHttpProtocol = UpstreamHttpProtocol.Http3; + return Task.CompletedTask; + }; + + using var client = testSuite.GetClient(proxy); + using var content = new ByteArrayContent(payload); + var response = await client.PostAsync($"https://localhost:{origin.Port}/post", content); + var body = await response.Content.ReadAsStringAsync(); + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, body); + Assert.AreEqual($"got={payload.Length}", body); + CollectionAssert.AreEqual(payload, seen); + } + + [TestMethod] + public async Task Http3Client_ForcedHttp11Origin_UsesTcpFallback() + { + RequireQuic(); + + using var testSuite = new TestSuite(); + var server = testSuite.GetServer(); + server.HandleRequest(async ctx => + { + await ctx.Response.WriteAsync("h3-to-h1"); + }); + + var quicEp = new TransparentQuicProxyEndPoint(IPAddress.Loopback, 0) + { + ForwardHost = "localhost", + ForwardPort = server.HttpsListeningPort + }; + quicEp.BeforeQuicAuthenticate += (_, args) => + { + args.UpstreamHttpProtocol = UpstreamHttpProtocol.Http11; + args.AllowHttpProtocolTranslation = true; + return Task.CompletedTask; + }; + + var proxy = new ProxyServer(false, false, false) + { + EnableHttp3 = true, + EnableHttpsSvcbDnsDiscovery = false + }; + proxy.CertificateManager.RootCertificateName = TestCertificateAuthority.RootCertificateName; + proxy.CertificateManager.RootCertificate = TestCertificateAuthority.RootCertificate; + proxy.CertificateManager.SaveFakeCertificates = false; + proxy.ServerCertificateValidationCallback += (_, args) => + { + args.IsValid = TestCertificateAuthority.Validate(args.Certificate, args.SslPolicyErrors); + return Task.CompletedTask; + }; + proxy.AddEndPoint(quicEp); + proxy.Start(); + + try + { + await using var client = await QuicHttp3Client.ConnectAsync( + new IPEndPoint(IPAddress.Loopback, quicEp.Port), "localhost"); + + // Authority must include the Kestrel HTTPS port — H3→TCP uses RequestUri, not ForwardPort. + var response = await client.SendAsync("GET", $"localhost:{server.HttpsListeningPort}", "/tcp"); + Assert.AreEqual(200, response.StatusCode, response.TextBody); + Assert.AreEqual("h3-to-h1", response.TextBody); + } + finally + { + proxy.Stop(); + proxy.Dispose(); + } + } + + [TestMethod] + public async Task Http2Client_WarmHttp3Origin_BridgesSuccessfully() + { + RequireQuic(); + + await using var origin = new QuicHttp3OriginServer(TestCertificateAuthority.ServerCertificate); + origin.HandleRequest(_ => Task.FromResult(new QuicHttp3Response(200, "h2-to-h3"))); + + using var testSuite = new TestSuite(); + var proxy = testSuite.GetProxy(); + proxy.EnableHttp2 = true; + proxy.EnableHttp3 = true; + proxy.EnableHttpsSvcbDnsDiscovery = false; + + // Seed capability + warm registry so Auto policy routes to H3 without waiting for Alt-Svc. + var hostAndPort = $"localhost:{origin.Port}"; + proxy.Http3OriginCapabilityCache.Set(hostAndPort, int.MinValue, TimeSpan.FromMinutes(5), targetName: null); + proxy.Http3WarmOrigins.Mark("localhost", origin.Port); + + proxy.BeforeRequest += (_, args) => + { + // Keep Auto so ResolveHttp3Origin uses the warm capability cache entry. + args.UpstreamHttpProtocol = UpstreamHttpProtocol.Auto; + return Task.CompletedTask; + }; + + using var client = TestHelper.GetHttp2Client(proxy); + var response = await client.GetAsync($"https://localhost:{origin.Port}/h2bridge"); + var body = await response.Content.ReadAsStringAsync(); + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, body); + Assert.AreEqual("h2-to-h3", body); + Assert.IsTrue(origin.AcceptedConnectionCount >= 1); + } + + [TestMethod] + public async Task Http2Client_ColdForcedHttp3_BridgesToQuicOrigin() + { + RequireQuic(); + + await using var origin = new QuicHttp3OriginServer(TestCertificateAuthority.ServerCertificate); + origin.HandleRequest(_ => Task.FromResult(new QuicHttp3Response(200, "h2-cold-h3"))); + + using var testSuite = new TestSuite(); + var proxy = testSuite.GetProxy(); + proxy.EnableHttp2 = true; + proxy.EnableHttp3 = true; + proxy.EnableHttpsSvcbDnsDiscovery = false; + + var endpoint = (ExplicitProxyEndPoint)proxy.ProxyEndPoints[0]; + endpoint.BeforeTunnelConnectRequest += (_, e) => + { + // Force H3 at CONNECT so SendHttp2ToHttp3Bridge runs with NullOriginStream (cold path). + e.UpstreamHttpProtocol = UpstreamHttpProtocol.Http3; + e.AllowHttpProtocolTranslation = true; + return Task.CompletedTask; + }; + + using var client = TestHelper.GetHttp2Client(proxy); + var response = await client.GetAsync($"https://localhost:{origin.Port}/cold"); + var body = await response.Content.ReadAsStringAsync(); + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, body); + Assert.AreEqual("h2-cold-h3", body); + Assert.IsTrue(origin.AcceptedConnectionCount >= 1); + } + + [TestMethod] + public async Task Http2Client_ColdH3Miss_FallsBackToTcpWithoutHang() + { + RequireQuic(); + + using var testSuite = new TestSuite(); + var server = testSuite.GetServer(); + server.HandleRequest(async ctx => + { + await ctx.Response.WriteAsync("cold-tcp-fallback"); + }); + + var proxy = testSuite.GetProxy(); + proxy.EnableHttp2 = true; + proxy.EnableHttp3 = true; + proxy.EnableHttpsSvcbDnsDiscovery = false; + + var endpoint = (ExplicitProxyEndPoint)proxy.ProxyEndPoints[0]; + endpoint.BeforeTunnelConnectRequest += (_, e) => + { + // Enter the cold NullOriginStream H2→H3 bridge at CONNECT time. + e.UpstreamHttpProtocol = UpstreamHttpProtocol.Http3; + e.AllowHttpProtocolTranslation = true; + return Task.CompletedTask; + }; + + proxy.BeforeRequest += (_, args) => + { + // Per-stream override: leave the cold bridge but force TCP so !UseH3 must self-fallback + // instead of writing into NullOriginStream (which would hang the client). + args.UpstreamHttpProtocol = UpstreamHttpProtocol.Http11; + return Task.CompletedTask; + }; + + using var client = TestHelper.GetHttp2Client(proxy); + var response = await client.GetAsync($"https://localhost:{server.HttpsListeningPort}/fallback"); + var body = await response.Content.ReadAsStringAsync(); + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, body); + Assert.AreEqual("cold-tcp-fallback", body); + } +} + +#pragma warning restore TWP001 +#pragma warning restore CA1416 From c18fbc12e31a99453e381cd6c4c9dd02ec1f843e Mon Sep 17 00:00:00 2001 From: justcoding121 Date: Sun, 2 Aug 2026 17:48:23 -0600 Subject: [PATCH 6/7] test: SVCB backoff/coalesce, compression chain, cert cache, WebSocket writer Cover UdpSvcbDnsResolver neg/transient cache and resolver backoff, CompressionUtil stacked decompress, disk-cache miss/corrupt PFX, WebSocketFrameWriter, and Http2OriginGoAwayException contract. Co-authored-by: Cursor --- .../CompressionUtilChainTests.cs | 67 ++++++++ .../DefaultCertificateDiskCacheTests.cs | 35 ++++ .../Http2OriginGoAwayExceptionTests.cs | 17 ++ .../SvcbDnsResolverTests.cs | 157 ++++++++++++++++++ .../WebSocketFrameWriterTests.cs | 63 +++++++ 5 files changed, 339 insertions(+) create mode 100644 tests/Titanium.Web.Proxy.UnitTests/CompressionUtilChainTests.cs create mode 100644 tests/Titanium.Web.Proxy.UnitTests/Http2OriginGoAwayExceptionTests.cs create mode 100644 tests/Titanium.Web.Proxy.UnitTests/WebSocketFrameWriterTests.cs diff --git a/tests/Titanium.Web.Proxy.UnitTests/CompressionUtilChainTests.cs b/tests/Titanium.Web.Proxy.UnitTests/CompressionUtilChainTests.cs new file mode 100644 index 00000000..9ca47b8b --- /dev/null +++ b/tests/Titanium.Web.Proxy.UnitTests/CompressionUtilChainTests.cs @@ -0,0 +1,67 @@ +using System.IO; +using System.IO.Compression; +using System.Text; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Titanium.Web.Proxy.Compression; + +namespace Titanium.Web.Proxy.UnitTests; + +[TestClass] +public class CompressionUtilChainTests +{ + [TestMethod] + public void CreateDecompressionChain_EmptyOrWhitespace_Passthrough() + { + using var inner = new MemoryStream(Encoding.UTF8.GetBytes("plain")); + var (stream, owned) = CompressionUtil.CreateDecompressionChain(inner, " "); + Assert.AreSame(inner, stream); + Assert.AreEqual(0, owned.Count); + } + + [TestMethod] + public void CreateDecompressionChain_UnsupportedLayer_ReturnsInnerUnchanged() + { + using var inner = new MemoryStream(Encoding.UTF8.GetBytes("x")); + var (stream, owned) = CompressionUtil.CreateDecompressionChain(inner, "gzip, exotic"); + Assert.AreSame(inner, stream); + Assert.AreEqual(0, owned.Count, "Unsupported stacked encodings must not partially wrap."); + } + + [TestMethod] + public void CreateDecompressionChain_StackedGzipDeflate_AppliesInReverseOrder() + { + var plain = Encoding.UTF8.GetBytes("stacked-body"); + + // Content-Encoding: gzip, deflate → applied gzip then deflate → wire is deflate(gzip(plain)). + byte[] gzipped; + using (var ms = new MemoryStream()) + { + using (var gzip = new GZipStream(ms, CompressionLevel.SmallestSize, leaveOpen: true)) + gzip.Write(plain); + gzipped = ms.ToArray(); + } + + byte[] wire; + using (var ms = new MemoryStream()) + { + using (var deflate = new DeflateStream(ms, CompressionLevel.SmallestSize, leaveOpen: true)) + deflate.Write(gzipped); + wire = ms.ToArray(); + } + + using var inner = new MemoryStream(wire); + var (stream, owned) = CompressionUtil.CreateDecompressionChain(inner, "gzip, deflate"); + try + { + Assert.AreEqual(2, owned.Count); + using var reader = new MemoryStream(); + stream.CopyTo(reader); + CollectionAssert.AreEqual(plain, reader.ToArray()); + } + finally + { + foreach (var layer in owned) + layer.Dispose(); + } + } +} diff --git a/tests/Titanium.Web.Proxy.UnitTests/DefaultCertificateDiskCacheTests.cs b/tests/Titanium.Web.Proxy.UnitTests/DefaultCertificateDiskCacheTests.cs index 5a7ea543..b7c73be1 100644 --- a/tests/Titanium.Web.Proxy.UnitTests/DefaultCertificateDiskCacheTests.cs +++ b/tests/Titanium.Web.Proxy.UnitTests/DefaultCertificateDiskCacheTests.cs @@ -108,4 +108,39 @@ public void PruneToMaxEntries_DeletesOldestFilesFirst_KeepingOnlyTheBound() } } + [TestMethod] + public void LoadRootCertificate_MissingFile_ReturnsNull() + { + var cache = new DefaultCertificateDiskCache(); + var missing = Path.Combine(Path.GetTempPath(), $"twp-missing-{Guid.NewGuid():N}.pfx"); + var loaded = cache.LoadRootCertificate(missing, "unused", X509KeyStorageFlags.Exportable); + Assert.IsNull(loaded); + } + + [TestMethod] + public void LoadCertificate_CorruptPfx_ReturnsNull() + { + if (!RunTime.IsWindows) + Assert.Inconclusive("PKCS#12 disk-cache characterization is Windows-focused."); + + var cache = new DefaultCertificateDiskCache(); + var certPathField = typeof(DefaultCertificateDiskCache).GetMethod("GetCertificatePath", + BindingFlags.NonPublic | BindingFlags.Instance); + Assert.IsNotNull(certPathField); + var certDir = (string)certPathField.Invoke(cache, new object[] { true })!; + var subject = $"corrupt-{Guid.NewGuid():N}.example"; + var filePath = Path.Combine(certDir, subject + ".pfx"); + + try + { + File.WriteAllBytes(filePath, new byte[] { 0x00, 0x01, 0x02, 0x03, 0xFF }); + var loaded = cache.LoadCertificate(subject, X509KeyStorageFlags.Exportable); + Assert.IsNull(loaded, "Corrupt PKCS#12 must be treated as a cache miss."); + } + finally + { + try { if (File.Exists(filePath)) File.Delete(filePath); } catch { /* best-effort */ } + } + } + } diff --git a/tests/Titanium.Web.Proxy.UnitTests/Http2OriginGoAwayExceptionTests.cs b/tests/Titanium.Web.Proxy.UnitTests/Http2OriginGoAwayExceptionTests.cs new file mode 100644 index 00000000..5442b1e6 --- /dev/null +++ b/tests/Titanium.Web.Proxy.UnitTests/Http2OriginGoAwayExceptionTests.cs @@ -0,0 +1,17 @@ +using System.IO; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Titanium.Web.Proxy.Http2; + +namespace Titanium.Web.Proxy.UnitTests; + +[TestClass] +public class Http2OriginGoAwayExceptionTests +{ + [TestMethod] + public void Http2OriginGoAwayException_IsIOException_WithMessage() + { + var ex = new Http2OriginGoAwayException("GOAWAY before stream processed"); + Assert.IsInstanceOfType(ex, typeof(IOException)); + Assert.AreEqual("GOAWAY before stream processed", ex.Message); + } +} diff --git a/tests/Titanium.Web.Proxy.UnitTests/SvcbDnsResolverTests.cs b/tests/Titanium.Web.Proxy.UnitTests/SvcbDnsResolverTests.cs index 43c918e0..27cf660a 100644 --- a/tests/Titanium.Web.Proxy.UnitTests/SvcbDnsResolverTests.cs +++ b/tests/Titanium.Web.Proxy.UnitTests/SvcbDnsResolverTests.cs @@ -479,6 +479,163 @@ public void TrimExpired_EnforcesHardCap_WhenNothingHasExpiredYet() $"negative cache must never exceed the hard cap of {cap}, had {cache.Count}"); } + // ───────────────────────────────────────────────────────────────────────── + // Coalesce / neg-cache / transient / backoff (no real DNS) + // ───────────────────────────────────────────────────────────────────────── + + private static ConcurrentDictionary GetTransientCache(UdpSvcbDnsResolver resolver) + { + var field = typeof(UdpSvcbDnsResolver).GetField("_transientCache", + BindingFlags.NonPublic | BindingFlags.Instance); + Assert.IsNotNull(field); + return (ConcurrentDictionary)field.GetValue(resolver)!; + } + + private static object GetInflight(UdpSvcbDnsResolver resolver) + { + var field = typeof(UdpSvcbDnsResolver).GetField("_inflight", + BindingFlags.NonPublic | BindingFlags.Instance); + Assert.IsNotNull(field); + return field.GetValue(resolver)!; + } + + private static int GetInflightCount(UdpSvcbDnsResolver resolver) => + (int)GetInflight(resolver).GetType().GetProperty("Count")!.GetValue(GetInflight(resolver))!; + + private static void InflightTryAdd(UdpSvcbDnsResolver resolver, string key, object completedTask) + { + var dict = GetInflight(resolver); + var tryAdd = dict.GetType().GetMethod("TryAdd")!; + var added = (bool)tryAdd.Invoke(dict, new[] { key, completedTask })!; + Assert.IsTrue(added); + } + + private static void SetBackoffState(UdpSvcbDnsResolver resolver, int consecutiveFailures, + DateTime backoffUntilUtc, int halfOpenInFlight = 0) + { + typeof(UdpSvcbDnsResolver).GetField("_consecutiveTransientFailures", + BindingFlags.NonPublic | BindingFlags.Instance)!.SetValue(resolver, consecutiveFailures); + typeof(UdpSvcbDnsResolver).GetField("_resolverBackoffUntilUtc", + BindingFlags.NonPublic | BindingFlags.Instance)!.SetValue(resolver, backoffUntilUtc); + typeof(UdpSvcbDnsResolver).GetField("_halfOpenProbeInFlight", + BindingFlags.NonPublic | BindingFlags.Instance)!.SetValue(resolver, halfOpenInFlight); + } + + [TestMethod] + public async System.Threading.Tasks.Task TryGetH3CapabilityAsync_NegativeCacheHit_SkipsDnsAndReturnsNull() + { + var resolver = new UdpSvcbDnsResolver(new IPEndPoint(IPAddress.Loopback, 1)); + GetNegativeCache(resolver)["neg.example:443"] = DateTime.UtcNow.AddMinutes(5); + + var result = await resolver.TryGetH3CapabilityAsync("neg.example", 443, default); + + Assert.IsNull(result); + Assert.AreEqual(0, GetInflightCount(resolver), "Negative-cache hit must not start a DNS probe."); + } + + [TestMethod] + public async System.Threading.Tasks.Task TryGetH3CapabilityAsync_TransientCacheHit_ReturnsNullWithoutProbe() + { + var resolver = new UdpSvcbDnsResolver(new IPEndPoint(IPAddress.Loopback, 1)); + GetTransientCache(resolver)["tmp.example:443"] = DateTime.UtcNow.AddSeconds(30); + + var result = await resolver.TryGetH3CapabilityAsync("tmp.example", 443, default); + + Assert.IsNull(result); + Assert.AreEqual(0, GetInflightCount(resolver)); + } + + [TestMethod] + public async System.Threading.Tasks.Task TryGetH3CapabilityAsync_ConcurrentCallers_ShareSingleInflightTask() + { + var resolver = new UdpSvcbDnsResolver(new IPEndPoint(IPAddress.Loopback, 1)); + var key = "coalesce.example:443"; + + // SvcbQueryState is a private nested type; build a completed Task via reflection. + var stateType = typeof(UdpSvcbDnsResolver).GetNestedType("SvcbQueryState", BindingFlags.NonPublic); + Assert.IsNotNull(stateType); + var stateCtor = stateType.GetConstructors(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)[0]; + var state = stateCtor.Invoke(new object?[] { null }); + var completed = typeof(System.Threading.Tasks.Task).GetMethod(nameof(System.Threading.Tasks.Task.FromResult))! + .MakeGenericMethod(stateType) + .Invoke(null, new[] { state })!; + + InflightTryAdd(resolver, key, completed); + + var a = resolver.TryGetH3CapabilityAsync("coalesce.example", 443, default); + var b = resolver.TryGetH3CapabilityAsync("coalesce.example", 443, default); + var ra = await a; + var rb = await b; + + Assert.IsNull(ra); + Assert.IsNull(rb); + Assert.AreEqual(1, GetInflightCount(resolver), "Both waiters must share the pre-seeded inflight task."); + } + + [TestMethod] + public async System.Threading.Tasks.Task TryGetH3CapabilityAsync_DuringBackoff_RejectsAllProbes() + { + var resolver = new UdpSvcbDnsResolver(new IPEndPoint(IPAddress.Loopback, 1)); + SetBackoffState(resolver, consecutiveFailures: 2, backoffUntilUtc: DateTime.UtcNow.AddMinutes(1)); + + var result = await resolver.TryGetH3CapabilityAsync("backoff.example", 443, default); + + Assert.IsNull(result); + Assert.AreEqual(0, GetInflightCount(resolver)); + } + + [TestMethod] + public async System.Threading.Tasks.Task TryGetH3CapabilityAsync_HalfOpenAlreadyInFlight_RejectsSecondCaller() + { + var resolver = new UdpSvcbDnsResolver(new IPEndPoint(IPAddress.Loopback, 1)); + // Backoff window elapsed, but a half-open probe is already reserved. + SetBackoffState(resolver, consecutiveFailures: 1, backoffUntilUtc: DateTime.UtcNow.AddMinutes(-1), + halfOpenInFlight: 1); + + var result = await resolver.TryGetH3CapabilityAsync("halfopen.example", 443, default); + + Assert.IsNull(result); + Assert.AreEqual(0, GetInflightCount(resolver)); + } + + [TestMethod] + public void NoteQueryTransientFailure_SetsBackoffWindow_WithExponentialGrowth() + { + var resolver = new UdpSvcbDnsResolver(new IPEndPoint(IPAddress.Loopback, 1)); + var note = typeof(UdpSvcbDnsResolver).GetMethod("NoteQueryTransientFailure", + BindingFlags.NonPublic | BindingFlags.Instance); + Assert.IsNotNull(note); + + note.Invoke(resolver, null); + var firstUntil = (DateTime)typeof(UdpSvcbDnsResolver) + .GetField("_resolverBackoffUntilUtc", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(resolver)!; + var firstFailures = (int)typeof(UdpSvcbDnsResolver) + .GetField("_consecutiveTransientFailures", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(resolver)!; + + Assert.AreEqual(1, firstFailures); + Assert.IsTrue(firstUntil > DateTime.UtcNow, + "First transient failure must open a future backoff window."); + + // Force the first window into the past so the second Note clearly advances absolute time. + typeof(UdpSvcbDnsResolver).GetField("_resolverBackoffUntilUtc", + BindingFlags.NonPublic | BindingFlags.Instance)!.SetValue(resolver, DateTime.UtcNow.AddSeconds(-1)); + + note.Invoke(resolver, null); + var secondUntil = (DateTime)typeof(UdpSvcbDnsResolver) + .GetField("_resolverBackoffUntilUtc", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(resolver)!; + var secondFailures = (int)typeof(UdpSvcbDnsResolver) + .GetField("_consecutiveTransientFailures", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(resolver)!; + + Assert.AreEqual(2, secondFailures); + Assert.IsTrue(secondUntil > DateTime.UtcNow); + Assert.IsTrue(secondUntil <= DateTime.UtcNow.AddMinutes(5).AddSeconds(1), + "Backoff must stay within the 5-minute cap."); + } + // ───────────────────────────────────────────────────────────────────────── // Additional packet builder helpers // ───────────────────────────────────────────────────────────────────────── diff --git a/tests/Titanium.Web.Proxy.UnitTests/WebSocketFrameWriterTests.cs b/tests/Titanium.Web.Proxy.UnitTests/WebSocketFrameWriterTests.cs new file mode 100644 index 00000000..8030efaf --- /dev/null +++ b/tests/Titanium.Web.Proxy.UnitTests/WebSocketFrameWriterTests.cs @@ -0,0 +1,63 @@ +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Titanium.Web.Proxy.StreamExtended.BufferPool; + +namespace Titanium.Web.Proxy.UnitTests; + +[TestClass] +public class WebSocketFrameWriterTests +{ + [TestMethod] + public async Task WriteTextAsync_WritesMaskedFrame_InvokesCallback_AndRoundTrips() + { + using var ms = new MemoryStream(); + using var writeLock = new SemaphoreSlim(1, 1); + var written = 0; + var writer = new WebSocketFrameWriter(ms, mask: true, writeLock, (_, _, count) => written += count); + + await writer.WriteTextAsync("hello-ws"); + + Assert.IsTrue(written > 0); + var wire = ms.ToArray(); + var decoder = new WebSocketDecoder(new FakeBufferPool(8192)); + var frame = decoder.Decode(wire, 0, wire.Length).Single(); + + Assert.AreEqual(WebsocketOpCode.Text, frame.OpCode); + Assert.AreEqual("hello-ws", Encoding.UTF8.GetString(frame.Data.ToArray())); + } + + [TestMethod] + public async Task WriteAsync_SerializesConcurrentWriters() + { + using var ms = new MemoryStream(); + using var writeLock = new SemaphoreSlim(1, 1); + var writer = new WebSocketFrameWriter(ms, mask: false, writeLock, null); + + await Task.WhenAll( + writer.WriteAsync(WebsocketOpCode.Text, Encoding.UTF8.GetBytes("one")), + writer.WriteAsync(WebsocketOpCode.Text, Encoding.UTF8.GetBytes("two")), + writer.WriteAsync(WebsocketOpCode.Text, Encoding.UTF8.GetBytes("three"))); + + var decoder = new WebSocketDecoder(new FakeBufferPool(8192)); + var wire = ms.ToArray(); + var frames = decoder.Decode(wire, 0, wire.Length).ToList(); + Assert.AreEqual(3, frames.Count); + CollectionAssert.AreEquivalent( + new[] { "one", "two", "three" }, + frames.Select(f => Encoding.UTF8.GetString(f.Data.ToArray())).ToArray()); + } + + private sealed class FakeBufferPool : IBufferPool + { + public FakeBufferPool(int bufferSize) => BufferSize = bufferSize; + public int BufferSize { get; } + public byte[] GetBuffer() => new byte[BufferSize]; + public byte[] GetBuffer(int bufferSize) => new byte[bufferSize]; + public void ReturnBuffer(byte[] buffer) { } + public void Dispose() { } + } +} From 8bbc945ad959535a8567b3b7874c902571cd4df8 Mon Sep 17 00:00:00 2001 From: justcoding121 Date: Sun, 2 Aug 2026 17:48:39 -0600 Subject: [PATCH 7/7] fix: reject empty :authority in Http2Helper.GetUri Replace the unfinished abc.abc placeholder with an InvalidOperationException so a missing authority cannot invent a host. Co-authored-by: Cursor --- src/Titanium.Web.Proxy/Http2/Http2Helper.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Titanium.Web.Proxy/Http2/Http2Helper.cs b/src/Titanium.Web.Proxy/Http2/Http2Helper.cs index 00fec088..3712bb99 100644 --- a/src/Titanium.Web.Proxy/Http2/Http2Helper.cs +++ b/src/Titanium.Web.Proxy/Http2/Http2Helper.cs @@ -3236,10 +3236,8 @@ private void MarkMalformed(string reason) public Uri GetUri() { if (Authority.Length == 0) - { - // todo - Authority = HttpHeader.Encoding.GetBytes("abc.abc"); - } + throw new InvalidOperationException( + "HTTP/2 request is missing the :authority pseudo-header."); var bytes = new byte[scheme.Length + 3 + Authority.Length + Path.Length]; scheme.Span.CopyTo(bytes);