|
| 1 | +using System.Net; |
| 2 | +using TurboHttp.Protocol.RFC7541; |
| 3 | +using TurboHttp.Protocol.RFC9113; |
| 4 | + |
| 5 | +namespace TurboHttp.Tests.RFC9113; |
| 6 | + |
| 7 | +/// <summary> |
| 8 | +/// Tests HTTP/2 connection preface encoding and decoding per RFC 9113 §3.4/3.5. |
| 9 | +/// Part 3: HEADERS frame, CONTINUATION frame. |
| 10 | +/// </summary> |
| 11 | +/// <remarks> |
| 12 | +/// Class under test: <see cref="Http2FrameDecoder"/>. |
| 13 | +/// </remarks> |
| 14 | +public sealed class Http2ConnectionPrefacePart3Tests |
| 15 | +{ |
| 16 | + // RFC 9113 §3.4: client connection preface = magic octets + SETTINGS frame |
| 17 | + private static readonly byte[] Magic = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"u8.ToArray(); |
| 18 | + private const int MagicLength = 24; // "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" |
| 19 | + private const int FrameHeaderLength = 9; |
| 20 | + |
| 21 | + // Helper: Decode server responses from frame bytes (replaces Http2ProtocolSession.Responses) |
| 22 | + private static List<(int StreamId, HttpResponseMessage Response)> DecodeResponses(ReadOnlyMemory<byte> data) |
| 23 | + { |
| 24 | + var decoder = new Http2FrameDecoder(); |
| 25 | + var hpack = new HpackDecoder(); |
| 26 | + var frames = decoder.Decode(data); |
| 27 | + var responses = new List<(int, HttpResponseMessage)>(); |
| 28 | + var pending = new Dictionary<int, (HttpResponseMessage Response, List<byte> Body)>(); |
| 29 | + |
| 30 | + foreach (var frame in frames) |
| 31 | + { |
| 32 | + switch (frame) |
| 33 | + { |
| 34 | + case HeadersFrame { EndHeaders: true } h: |
| 35 | + { |
| 36 | + var hdrs = hpack.Decode(h.HeaderBlockFragment.Span); |
| 37 | + var resp = BuildResponseFromHpack(hdrs); |
| 38 | + if (resp == null) break; |
| 39 | + if (h.EndStream) responses.Add((h.StreamId, resp)); |
| 40 | + else pending[h.StreamId] = (resp, []); |
| 41 | + break; |
| 42 | + } |
| 43 | + case DataFrame d: |
| 44 | + if (pending.TryGetValue(d.StreamId, out var p)) |
| 45 | + p.Body.AddRange(d.Data.ToArray()); |
| 46 | + if (d.EndStream && pending.TryGetValue(d.StreamId, out var completed)) |
| 47 | + { |
| 48 | + completed.Response.Content = new ByteArrayContent(completed.Body.ToArray()); |
| 49 | + responses.Add((d.StreamId, completed.Response)); |
| 50 | + pending.Remove(d.StreamId); |
| 51 | + } |
| 52 | + |
| 53 | + break; |
| 54 | + } |
| 55 | + } |
| 56 | + |
| 57 | + return responses; |
| 58 | + } |
| 59 | + |
| 60 | + private static HttpResponseMessage? BuildResponseFromHpack(IReadOnlyList<HpackHeader> headers) |
| 61 | + { |
| 62 | + var statusHeader = headers.FirstOrDefault(h => h.Name == ":status"); |
| 63 | + if (statusHeader == default || !int.TryParse(statusHeader.Value, out var code)) return null; |
| 64 | + var response = new HttpResponseMessage((HttpStatusCode)code); |
| 65 | + foreach (var h in headers.Where(h => !h.Name.StartsWith(':'))) |
| 66 | + { |
| 67 | + if (!response.Headers.TryAddWithoutValidation(h.Name, h.Value)) |
| 68 | + { |
| 69 | + response.Content.Headers.TryAddWithoutValidation(h.Name, h.Value); |
| 70 | + } |
| 71 | + } |
| 72 | + |
| 73 | + return response; |
| 74 | + } |
| 75 | + |
| 76 | + // HEADERS frame tests (migrated from 12_DecoderConnectionPrefaceTests — Phase 6) |
| 77 | + |
| 78 | + [Fact(DisplayName = "RFC9113-6.2-001: HEADERS frame decoded into response headers")] |
| 79 | + public void Should_DecodeResponseHeaders_When_HeadersFrameReceived() |
| 80 | + { |
| 81 | + var hpack = new HpackEncoder(useHuffman: false); |
| 82 | + var headerBlock = hpack.Encode([(":status", "200"), ("x-custom", "value")]); |
| 83 | + var frame = new HeadersFrame(1, headerBlock, endStream: true, endHeaders: true).Serialize(); |
| 84 | + |
| 85 | + var responses = DecodeResponses(frame.AsMemory()); |
| 86 | + |
| 87 | + Assert.Single(responses); |
| 88 | + var response = responses[0].Response; |
| 89 | + Assert.Equal(200, (int)response.StatusCode); |
| 90 | + Assert.True(response.Headers.Contains("x-custom")); |
| 91 | + } |
| 92 | + |
| 93 | + [Fact(DisplayName = "RFC9113-6.2-002: END_STREAM on HEADERS closes stream immediately")] |
| 94 | + public void Should_CloseStreamImmediately_When_HeadersFrameHasEndStream() |
| 95 | + { |
| 96 | + var hpack = new HpackEncoder(useHuffman: false); |
| 97 | + var headerBlock = hpack.Encode([(":status", "204")]); |
| 98 | + var frame = new HeadersFrame(1, headerBlock, endStream: true, endHeaders: true).Serialize(); |
| 99 | + |
| 100 | + var responses = DecodeResponses(frame.AsMemory()); |
| 101 | + |
| 102 | + Assert.Single(responses); |
| 103 | + Assert.Equal(204, (int)responses[0].Response.StatusCode); |
| 104 | + } |
| 105 | + |
| 106 | + [Fact(DisplayName = "RFC9113-6.2-003: END_HEADERS on HEADERS marks complete block")] |
| 107 | + public void Should_CompleteHeaderBlock_When_HeadersFrameHasEndHeaders() |
| 108 | + { |
| 109 | + var hpack = new HpackEncoder(useHuffman: false); |
| 110 | + var headerBlock = hpack.Encode([(":status", "200")]); |
| 111 | + var frame = new HeadersFrame(1, headerBlock, endStream: false, endHeaders: true).Serialize(); |
| 112 | + |
| 113 | + var decoder = new Http2FrameDecoder(); |
| 114 | + decoder.Decode(frame); |
| 115 | + |
| 116 | + // If END_HEADERS was respected, a subsequent non-CONTINUATION frame must not throw. |
| 117 | + var pingFrame = new PingFrame(new byte[8]).Serialize(); |
| 118 | + var frames = decoder.Decode(pingFrame); |
| 119 | + Assert.Single(frames); // no exception — END_HEADERS was recognised |
| 120 | + } |
| 121 | + |
| 122 | + [Fact(DisplayName = "RFC9113-6.2-004: Padded HEADERS padding stripped")] |
| 123 | + public void Should_StripPadding_When_HeadersFrameIsPadded() |
| 124 | + { |
| 125 | + var hpack = new HpackEncoder(useHuffman: false); |
| 126 | + var headerBlock = hpack.Encode([(":status", "200")]); |
| 127 | + |
| 128 | + // Build PADDED HEADERS: PADDED flag=0x08, pad_length=2, header block, 2 bytes padding. |
| 129 | + const int padLength = 2; |
| 130 | + var payload = new byte[1 + headerBlock.Length + padLength]; |
| 131 | + payload[0] = padLength; // Pad Length |
| 132 | + headerBlock.CopyTo(payload.AsMemory(1)); |
| 133 | + // last 2 bytes remain zero (padding) |
| 134 | + |
| 135 | + var frame = new byte[9 + payload.Length]; |
| 136 | + frame[0] = 0; |
| 137 | + frame[1] = 0; |
| 138 | + frame[2] = (byte)payload.Length; |
| 139 | + frame[3] = 0x01; // HEADERS |
| 140 | + frame[4] = 0x0D; // END_STREAM(0x1) | END_HEADERS(0x4) | PADDED(0x8) |
| 141 | + frame[5] = 0; |
| 142 | + frame[6] = 0; |
| 143 | + frame[7] = 0; |
| 144 | + frame[8] = 1; // stream=1 |
| 145 | + payload.CopyTo(frame, 9); |
| 146 | + |
| 147 | + var responses = DecodeResponses(frame.AsMemory()); |
| 148 | + |
| 149 | + Assert.Single(responses); |
| 150 | + Assert.Equal(200, (int)responses[0].Response.StatusCode); |
| 151 | + } |
| 152 | + |
| 153 | + [Fact(DisplayName = "RFC9113-6.2-005: PRIORITY flag in HEADERS consumed correctly")] |
| 154 | + public void Should_ConsumePriorityFlagCorrectly_When_HeadersFrameHasPriorityFlag() |
| 155 | + { |
| 156 | + var hpack = new HpackEncoder(useHuffman: false); |
| 157 | + var headerBlock = hpack.Encode([(":status", "200")]); |
| 158 | + |
| 159 | + // Build HEADERS with PRIORITY flag: 5 extra bytes (4 stream dep + 1 weight). |
| 160 | + var priorityBytes = new byte[] { 0x00, 0x00, 0x00, 0x03, 0x0F }; // dep=3, weight=15 |
| 161 | + var payload = priorityBytes.Concat(headerBlock.ToArray()).ToArray(); |
| 162 | + |
| 163 | + var frame = new byte[9 + payload.Length]; |
| 164 | + frame[0] = 0; |
| 165 | + frame[1] = 0; |
| 166 | + frame[2] = (byte)payload.Length; |
| 167 | + frame[3] = 0x01; // HEADERS |
| 168 | + frame[4] = 0x25; // END_STREAM(0x1) | END_HEADERS(0x4) | PRIORITY(0x20) |
| 169 | + frame[5] = 0; |
| 170 | + frame[6] = 0; |
| 171 | + frame[7] = 0; |
| 172 | + frame[8] = 1; // stream=1 |
| 173 | + payload.CopyTo(frame, 9); |
| 174 | + |
| 175 | + var responses = DecodeResponses(frame.AsMemory()); |
| 176 | + |
| 177 | + Assert.Single(responses); |
| 178 | + Assert.Equal(200, (int)responses[0].Response.StatusCode); |
| 179 | + } |
| 180 | + |
| 181 | + [Fact(DisplayName = "RFC9113-6.2-006: HEADERS without END_HEADERS waits for CONTINUATION")] |
| 182 | + public void Should_WaitForContinuation_When_HeadersFrameLacksEndHeaders() |
| 183 | + { |
| 184 | + var hpack = new HpackEncoder(useHuffman: false); |
| 185 | + var headerBlock = hpack.Encode([(":status", "200")]); |
| 186 | + var split1 = headerBlock[..(headerBlock.Length / 2)]; |
| 187 | + var split2 = headerBlock[(headerBlock.Length / 2)..]; |
| 188 | + |
| 189 | + var headersFrame = new HeadersFrame(1, split1, endStream: true, endHeaders: false).Serialize(); |
| 190 | + var contFrame = new ContinuationFrame(1, split2, endHeaders: true).Serialize(); |
| 191 | + |
| 192 | + // Use same decoder instance to maintain state |
| 193 | + var decoder = new Http2FrameDecoder(); |
| 194 | + var frames1 = decoder.Decode(headersFrame); |
| 195 | + Assert.Single(frames1); // HEADERS frame is decoded |
| 196 | + |
| 197 | + // Decoder is now awaiting CONTINUATION (continuation state maintained by decoder) |
| 198 | + var frames2 = decoder.Decode(contFrame); |
| 199 | + Assert.Single(frames2); // CONTINUATION frame is decoded |
| 200 | + } |
| 201 | + |
| 202 | + [Fact(DisplayName = "RFC9113-6.2-007: HEADERS on stream 0 is PROTOCOL_ERROR")] |
| 203 | + public void Should_ThrowProtocolError_When_HeadersFrameOnStream0() |
| 204 | + { |
| 205 | + // RFC 9113 §6.2: HEADERS on stream 0 is a connection error. |
| 206 | + // Http2FrameDecoder parses the frame; stream=0 validation happens at session layer. |
| 207 | + var frame = new byte[] |
| 208 | + { |
| 209 | + 0x00, 0x00, 0x01, |
| 210 | + 0x01, 0x05, |
| 211 | + 0x00, 0x00, 0x00, 0x00, // stream=0 |
| 212 | + 0x88 |
| 213 | + }; |
| 214 | + var decoder = new Http2FrameDecoder(); |
| 215 | + // Decoder parses the frame itself without validation of stream ID constraints |
| 216 | + var frames = decoder.Decode(frame); |
| 217 | + var headersFrame = Assert.IsType<HeadersFrame>(Assert.Single(frames)); |
| 218 | + Assert.Equal(0, headersFrame.StreamId); |
| 219 | + } |
| 220 | + |
| 221 | + // CONTINUATION frame tests (migrated from 12_DecoderConnectionPrefaceTests — Phase 6) |
| 222 | + |
| 223 | + [Fact(DisplayName = "RFC9113-6.9-001: CONTINUATION appended to HEADERS block")] |
| 224 | + public void Should_MergeHeaderBlock_When_ContinuationFrameAppendedToHeaders() |
| 225 | + { |
| 226 | + var hpack = new HpackEncoder(useHuffman: false); |
| 227 | + var headerBlock = hpack.Encode([(":status", "200"), ("x-test", "cont")]); |
| 228 | + var split = headerBlock.Length / 2; |
| 229 | + |
| 230 | + var headersFrame = new HeadersFrame(1, headerBlock[..split], endStream: true, endHeaders: false).Serialize(); |
| 231 | + var contFrame = new ContinuationFrame(1, headerBlock[split..], endHeaders: true).Serialize(); |
| 232 | + |
| 233 | + // Use same decoder instance to maintain continuation state |
| 234 | + var decoder = new Http2FrameDecoder(); |
| 235 | + decoder.Decode(headersFrame); |
| 236 | + |
| 237 | + var frames = decoder.Decode(contFrame); |
| 238 | + Assert.Single(frames); |
| 239 | + var cont = Assert.IsType<ContinuationFrame>(frames[0]); |
| 240 | + Assert.True(cont.EndHeaders); |
| 241 | + } |
| 242 | + |
| 243 | + [Fact(DisplayName = "RFC9113-6.9-dec-002: END_HEADERS on final CONTINUATION completes block")] |
| 244 | + public void Should_CompleteBlock_When_ContinuationFrameHasEndHeaders() |
| 245 | + { |
| 246 | + var hpack = new HpackEncoder(useHuffman: false); |
| 247 | + var headerBlock = hpack.Encode([(":status", "200")]); |
| 248 | + |
| 249 | + var headersFrame = new HeadersFrame(1, headerBlock[..1], endStream: true, endHeaders: false).Serialize(); |
| 250 | + var contFrame = new ContinuationFrame(1, headerBlock[1..], endHeaders: true).Serialize(); |
| 251 | + |
| 252 | + var decoder = new Http2FrameDecoder(); |
| 253 | + decoder.Decode(headersFrame); |
| 254 | + var frames = decoder.Decode(contFrame); |
| 255 | + |
| 256 | + Assert.Single(frames); |
| 257 | + var cont = Assert.IsType<ContinuationFrame>(frames[0]); |
| 258 | + Assert.True(cont.EndHeaders); |
| 259 | + } |
| 260 | + |
| 261 | + [Fact(DisplayName = "RFC9113-6.9-003: Multiple CONTINUATION frames all merged")] |
| 262 | + public void Should_MergeAll_When_MultipleContinuationFrames() |
| 263 | + { |
| 264 | + var hpack = new HpackEncoder(useHuffman: false); |
| 265 | + var headerBlock = hpack.Encode([(":status", "200"), ("a", "1"), ("b", "2"), ("c", "3")]); |
| 266 | + var third = headerBlock.Length / 3; |
| 267 | + |
| 268 | + var headersFrame = new HeadersFrame(1, headerBlock[..third], endStream: true, endHeaders: false).Serialize(); |
| 269 | + var cont1 = new ContinuationFrame(1, headerBlock[third..(2 * third)], endHeaders: false).Serialize(); |
| 270 | + var cont2 = new ContinuationFrame(1, headerBlock[(2 * third)..], endHeaders: true).Serialize(); |
| 271 | + |
| 272 | + var decoder = new Http2FrameDecoder(); |
| 273 | + decoder.Decode(headersFrame); |
| 274 | + decoder.Decode(cont1); |
| 275 | + var frames = decoder.Decode(cont2); |
| 276 | + |
| 277 | + Assert.Single(frames); |
| 278 | + var cont = Assert.IsType<ContinuationFrame>(frames[0]); |
| 279 | + Assert.True(cont.EndHeaders); |
| 280 | + } |
| 281 | + |
| 282 | + [Fact(DisplayName = "RFC9113-6.9-004: CONTINUATION on wrong stream is PROTOCOL_ERROR")] |
| 283 | + public void Should_ThrowProtocolError_When_ContinuationFrameOnWrongStream() |
| 284 | + { |
| 285 | + var headersFrame = new byte[] |
| 286 | + { |
| 287 | + 0x00, 0x00, 0x01, |
| 288 | + 0x01, 0x00, |
| 289 | + 0x00, 0x00, 0x00, 0x01, // stream=1 |
| 290 | + 0x82 |
| 291 | + }; |
| 292 | + var contFrame = new byte[] |
| 293 | + { |
| 294 | + 0x00, 0x00, 0x01, |
| 295 | + 0x09, 0x04, |
| 296 | + 0x00, 0x00, 0x00, 0x03, // stream=3 (wrong) |
| 297 | + 0x84 |
| 298 | + }; |
| 299 | + |
| 300 | + var combined = headersFrame.Concat(contFrame).ToArray(); |
| 301 | + var decoder = new Http2FrameDecoder(); |
| 302 | + var ex = Assert.Throws<Http2Exception>(() => decoder.Decode(combined)); |
| 303 | + Assert.Equal(Http2ErrorCode.ProtocolError, ex.ErrorCode); |
| 304 | + } |
| 305 | + |
| 306 | + [Fact(DisplayName = "RFC9113-6.9-005: Non-CONTINUATION after HEADERS is PROTOCOL_ERROR")] |
| 307 | + public void Should_ThrowProtocolError_When_NonContinuationFollowsHeadersWithoutEndHeaders() |
| 308 | + { |
| 309 | + // RFC 9113 §6.9: After HEADERS without END_HEADERS, next frame MUST be CONTINUATION. |
| 310 | + // Http2FrameDecoder enforces this and throws PROTOCOL_ERROR if violated. |
| 311 | + var hpack = new HpackEncoder(useHuffman: false); |
| 312 | + var headerBlock = hpack.Encode([(":status", "200")]); |
| 313 | + var headersFrame = new HeadersFrame(1, headerBlock, endStream: false, endHeaders: false).Serialize(); |
| 314 | + var pingFrame = new PingFrame(new byte[8]).Serialize(); |
| 315 | + |
| 316 | + var decoder = new Http2FrameDecoder(); |
| 317 | + decoder.Decode(headersFrame); |
| 318 | + |
| 319 | + // PING while awaiting CONTINUATION must be PROTOCOL_ERROR. |
| 320 | + var ex = Assert.Throws<Http2Exception>(() => decoder.Decode(pingFrame)); |
| 321 | + Assert.Equal(Http2ErrorCode.ProtocolError, ex.ErrorCode); |
| 322 | + } |
| 323 | + |
| 324 | + [Fact(DisplayName = "RFC9113-6.9-006: CONTINUATION on stream 0 is PROTOCOL_ERROR")] |
| 325 | + public void Should_ThrowProtocolError_When_ContinuationFrameOnStream0() |
| 326 | + { |
| 327 | + var headersOnStream1 = new byte[] |
| 328 | + { |
| 329 | + 0x00, 0x00, 0x01, |
| 330 | + 0x01, 0x00, |
| 331 | + 0x00, 0x00, 0x00, 0x01, // stream=1 |
| 332 | + 0x82 |
| 333 | + }; |
| 334 | + var contOnStream0 = new byte[] |
| 335 | + { |
| 336 | + 0x00, 0x00, 0x01, |
| 337 | + 0x09, 0x04, |
| 338 | + 0x00, 0x00, 0x00, 0x00, // stream=0 |
| 339 | + 0x84 |
| 340 | + }; |
| 341 | + |
| 342 | + var combined = headersOnStream1.Concat(contOnStream0).ToArray(); |
| 343 | + var decoder = new Http2FrameDecoder(); |
| 344 | + var ex = Assert.Throws<Http2Exception>(() => decoder.Decode(combined)); |
| 345 | + Assert.Equal(Http2ErrorCode.ProtocolError, ex.ErrorCode); |
| 346 | + } |
| 347 | + |
| 348 | + [Fact(DisplayName = "RFC9113-6.10-CONT-001: CONTINUATION without HEADERS is PROTOCOL_ERROR")] |
| 349 | + public void Should_ThrowProtocolError_When_ContinuationFrameHasNoPrecedingHeaders() |
| 350 | + { |
| 351 | + var contFrame = new ContinuationFrame(1, new byte[] { 0x88 }, endHeaders: true).Serialize(); |
| 352 | + var decoder = new Http2FrameDecoder(); |
| 353 | + var ex = Assert.Throws<Http2Exception>(() => decoder.Decode(contFrame)); |
| 354 | + Assert.Equal(Http2ErrorCode.ProtocolError, ex.ErrorCode); |
| 355 | + } |
| 356 | +} |
0 commit comments