| // Copyright 2024 The Go Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| //go:build go1.24 && goexperiment.synctest |
| |
| package http3 |
| |
| import ( |
| "bytes" |
| "errors" |
| "io" |
| "net/http" |
| "testing" |
| "testing/synctest" |
| |
| "golang.org/x/net/quic" |
| ) |
| |
| func TestRoundTripSimple(t *testing.T) { |
| runSynctest(t, func(t testing.TB) { |
| tc := newTestClientConn(t) |
| tc.greet() |
| |
| req, _ := http.NewRequest("GET", "https://example.tld/", nil) |
| req.Header["User-Agent"] = nil |
| rt := tc.roundTrip(req) |
| st := tc.wantStream(streamTypeRequest) |
| st.wantHeaders(http.Header{ |
| ":authority": []string{"example.tld"}, |
| ":method": []string{"GET"}, |
| ":path": []string{"/"}, |
| ":scheme": []string{"https"}, |
| }) |
| st.writeHeaders(http.Header{ |
| ":status": []string{"200"}, |
| "x-some-header": []string{"value"}, |
| }) |
| rt.wantStatus(200) |
| rt.wantHeaders(http.Header{ |
| "X-Some-Header": []string{"value"}, |
| }) |
| }) |
| } |
| |
| func TestRoundTripWithBadHeaders(t *testing.T) { |
| runSynctest(t, func(t testing.TB) { |
| tc := newTestClientConn(t) |
| tc.greet() |
| |
| req, _ := http.NewRequest("GET", "https://example.tld/", nil) |
| req.Header["Invalid\nHeader"] = []string{"x"} |
| rt := tc.roundTrip(req) |
| rt.wantError("RoundTrip fails when request contains invalid headers") |
| }) |
| } |
| |
| func TestRoundTripWithUnknownFrame(t *testing.T) { |
| runSynctest(t, func(t testing.TB) { |
| tc := newTestClientConn(t) |
| tc.greet() |
| |
| req, _ := http.NewRequest("GET", "https://example.tld/", nil) |
| rt := tc.roundTrip(req) |
| st := tc.wantStream(streamTypeRequest) |
| st.wantHeaders(nil) |
| |
| // Write an unknown frame type before the response HEADERS. |
| data := "frame content" |
| st.writeVarint(0x1f + 0x21) // reserved frame type |
| st.writeVarint(int64(len(data))) // size |
| st.Write([]byte(data)) |
| |
| st.writeHeaders(http.Header{ |
| ":status": []string{"200"}, |
| }) |
| rt.wantStatus(200) |
| }) |
| } |
| |
| func TestRoundTripWithInvalidPushPromise(t *testing.T) { |
| // "A client MUST treat receipt of a PUSH_PROMISE frame that contains |
| // a larger push ID than the client has advertised as a connection error of H3_ID_ERROR." |
| // https://www.rfc-editor.org/rfc/rfc9114.html#section-7.2.5-5 |
| runSynctest(t, func(t testing.TB) { |
| tc := newTestClientConn(t) |
| tc.greet() |
| |
| req, _ := http.NewRequest("GET", "https://example.tld/", nil) |
| rt := tc.roundTrip(req) |
| st := tc.wantStream(streamTypeRequest) |
| st.wantHeaders(nil) |
| |
| // Write a PUSH_PROMISE frame. |
| // Since the client hasn't indicated willingness to accept pushes, |
| // this is a connection error. |
| st.writePushPromise(0, http.Header{ |
| ":path": []string{"/foo"}, |
| }) |
| rt.wantError("RoundTrip fails after receiving invalid PUSH_PROMISE") |
| tc.wantClosed( |
| "push ID exceeds client's MAX_PUSH_ID", |
| errH3IDError, |
| ) |
| }) |
| } |
| |
| func TestRoundTripResponseContentLength(t *testing.T) { |
| for _, test := range []struct { |
| name string |
| respHeader http.Header |
| wantContentLength int64 |
| wantError bool |
| }{{ |
| name: "valid", |
| respHeader: http.Header{ |
| ":status": []string{"200"}, |
| "content-length": []string{"100"}, |
| }, |
| wantContentLength: 100, |
| }, { |
| name: "absent", |
| respHeader: http.Header{ |
| ":status": []string{"200"}, |
| }, |
| wantContentLength: -1, |
| }, { |
| name: "unparseable", |
| respHeader: http.Header{ |
| ":status": []string{"200"}, |
| "content-length": []string{"1 1"}, |
| }, |
| wantError: true, |
| }, { |
| name: "duplicated", |
| respHeader: http.Header{ |
| ":status": []string{"200"}, |
| "content-length": []string{"500", "500", "500"}, |
| }, |
| wantContentLength: 500, |
| }, { |
| name: "inconsistent", |
| respHeader: http.Header{ |
| ":status": []string{"200"}, |
| "content-length": []string{"1", "2"}, |
| }, |
| wantError: true, |
| }, { |
| // 204 responses aren't allowed to contain a Content-Length header. |
| // We just ignore it. |
| name: "204", |
| respHeader: http.Header{ |
| ":status": []string{"204"}, |
| "content-length": []string{"100"}, |
| }, |
| wantContentLength: -1, |
| }} { |
| runSynctestSubtest(t, test.name, func(t testing.TB) { |
| tc := newTestClientConn(t) |
| tc.greet() |
| |
| req, _ := http.NewRequest("GET", "https://example.tld/", nil) |
| rt := tc.roundTrip(req) |
| st := tc.wantStream(streamTypeRequest) |
| st.wantHeaders(nil) |
| st.writeHeaders(test.respHeader) |
| if test.wantError { |
| rt.wantError("invalid content-length in response") |
| return |
| } |
| if got, want := rt.response().ContentLength, test.wantContentLength; got != want { |
| t.Errorf("Response.ContentLength = %v, want %v", got, want) |
| } |
| }) |
| } |
| } |
| |
| func TestRoundTripMalformedResponses(t *testing.T) { |
| for _, test := range []struct { |
| name string |
| respHeader http.Header |
| }{{ |
| name: "duplicate :status", |
| respHeader: http.Header{ |
| ":status": []string{"200", "204"}, |
| }, |
| }, { |
| name: "unparseable :status", |
| respHeader: http.Header{ |
| ":status": []string{"frogpants"}, |
| }, |
| }, { |
| name: "undefined pseudo-header", |
| respHeader: http.Header{ |
| ":status": []string{"200"}, |
| ":unknown": []string{"x"}, |
| }, |
| }, { |
| name: "no :status", |
| respHeader: http.Header{}, |
| }} { |
| runSynctestSubtest(t, test.name, func(t testing.TB) { |
| tc := newTestClientConn(t) |
| tc.greet() |
| |
| req, _ := http.NewRequest("GET", "https://example.tld/", nil) |
| rt := tc.roundTrip(req) |
| st := tc.wantStream(streamTypeRequest) |
| st.wantHeaders(nil) |
| st.writeHeaders(test.respHeader) |
| rt.wantError("malformed response") |
| }) |
| } |
| } |
| |
| func TestRoundTripCrumbledCookiesInResponse(t *testing.T) { |
| // "If a decompressed field section contains multiple cookie field lines, |
| // these MUST be concatenated into a single byte string [...]" |
| // using the two-byte delimiter of "; "'' |
| // https://www.rfc-editor.org/rfc/rfc9114.html#section-4.2.1-2 |
| runSynctest(t, func(t testing.TB) { |
| tc := newTestClientConn(t) |
| tc.greet() |
| |
| req, _ := http.NewRequest("GET", "https://example.tld/", nil) |
| rt := tc.roundTrip(req) |
| st := tc.wantStream(streamTypeRequest) |
| st.wantHeaders(nil) |
| st.writeHeaders(http.Header{ |
| ":status": []string{"200"}, |
| "cookie": []string{"a=1", "b=2; c=3", "d=4"}, |
| }) |
| rt.wantStatus(200) |
| rt.wantHeaders(http.Header{ |
| "Cookie": []string{"a=1; b=2; c=3; d=4"}, |
| }) |
| }) |
| } |
| |
| func TestRoundTripResponseBody(t *testing.T) { |
| // These tests consist of a series of steps, |
| // where each step is either something arriving on the response stream |
| // or the client reading from the request body. |
| type ( |
| // HEADERS frame arrives on the response stream (headers or trailers). |
| receiveHeaders http.Header |
| // DATA frame header arrives on the response stream. |
| receiveDataHeader struct { |
| size int64 |
| } |
| // DATA frame content arrives on the response stream. |
| receiveData struct { |
| size int64 |
| } |
| // Some other frame arrives on the response stream. |
| receiveFrame struct { |
| ftype frameType |
| data []byte |
| } |
| // Response stream closed, ending the body. |
| receiveEOF struct{} |
| // Client reads from Response.Body. |
| wantBody struct { |
| size int64 |
| eof bool |
| } |
| wantError struct{} |
| ) |
| for _, test := range []struct { |
| name string |
| respHeader http.Header |
| steps []any |
| wantError bool |
| }{{ |
| name: "no content length", |
| steps: []any{ |
| receiveHeaders{ |
| ":status": []string{"200"}, |
| }, |
| receiveDataHeader{size: 10}, |
| receiveData{size: 10}, |
| receiveEOF{}, |
| wantBody{size: 10, eof: true}, |
| }, |
| }, { |
| name: "valid content length", |
| steps: []any{ |
| receiveHeaders{ |
| ":status": []string{"200"}, |
| "content-length": []string{"10"}, |
| }, |
| receiveDataHeader{size: 10}, |
| receiveData{size: 10}, |
| receiveEOF{}, |
| wantBody{size: 10, eof: true}, |
| }, |
| }, { |
| name: "data frame exceeds content length", |
| steps: []any{ |
| receiveHeaders{ |
| ":status": []string{"200"}, |
| "content-length": []string{"5"}, |
| }, |
| receiveDataHeader{size: 10}, |
| receiveData{size: 10}, |
| wantError{}, |
| }, |
| }, { |
| name: "data frame after all content read", |
| steps: []any{ |
| receiveHeaders{ |
| ":status": []string{"200"}, |
| "content-length": []string{"5"}, |
| }, |
| receiveDataHeader{size: 5}, |
| receiveData{size: 5}, |
| wantBody{size: 5}, |
| receiveDataHeader{size: 1}, |
| receiveData{size: 1}, |
| wantError{}, |
| }, |
| }, { |
| name: "content length too long", |
| steps: []any{ |
| receiveHeaders{ |
| ":status": []string{"200"}, |
| "content-length": []string{"10"}, |
| }, |
| receiveDataHeader{size: 5}, |
| receiveData{size: 5}, |
| receiveEOF{}, |
| wantBody{size: 5}, |
| wantError{}, |
| }, |
| }, { |
| name: "stream ended by trailers", |
| steps: []any{ |
| receiveHeaders{ |
| ":status": []string{"200"}, |
| }, |
| receiveDataHeader{size: 5}, |
| receiveData{size: 5}, |
| receiveHeaders{ |
| "x-trailer": []string{"value"}, |
| }, |
| wantBody{size: 5, eof: true}, |
| }, |
| }, { |
| name: "trailers and content length too long", |
| steps: []any{ |
| receiveHeaders{ |
| ":status": []string{"200"}, |
| "content-length": []string{"10"}, |
| }, |
| receiveDataHeader{size: 5}, |
| receiveData{size: 5}, |
| wantBody{size: 5}, |
| receiveHeaders{ |
| "x-trailer": []string{"value"}, |
| }, |
| wantError{}, |
| }, |
| }, { |
| name: "unknown frame before headers", |
| steps: []any{ |
| receiveFrame{ |
| ftype: 0x1f + 0x21, // reserved frame type |
| data: []byte{1, 2, 3, 4}, |
| }, |
| receiveHeaders{ |
| ":status": []string{"200"}, |
| }, |
| receiveDataHeader{size: 10}, |
| receiveData{size: 10}, |
| wantBody{size: 10}, |
| }, |
| }, { |
| name: "unknown frame after headers", |
| steps: []any{ |
| receiveHeaders{ |
| ":status": []string{"200"}, |
| }, |
| receiveFrame{ |
| ftype: 0x1f + 0x21, // reserved frame type |
| data: []byte{1, 2, 3, 4}, |
| }, |
| receiveDataHeader{size: 10}, |
| receiveData{size: 10}, |
| wantBody{size: 10}, |
| }, |
| }, { |
| name: "invalid frame", |
| steps: []any{ |
| receiveHeaders{ |
| ":status": []string{"200"}, |
| }, |
| receiveFrame{ |
| ftype: frameTypeSettings, // not a valid frame on this stream |
| data: []byte{1, 2, 3, 4}, |
| }, |
| wantError{}, |
| }, |
| }, { |
| name: "data frame consumed by several reads", |
| steps: []any{ |
| receiveHeaders{ |
| ":status": []string{"200"}, |
| }, |
| receiveDataHeader{size: 16}, |
| receiveData{size: 16}, |
| wantBody{size: 2}, |
| wantBody{size: 4}, |
| wantBody{size: 8}, |
| wantBody{size: 2}, |
| }, |
| }, { |
| name: "read multiple frames", |
| steps: []any{ |
| receiveHeaders{ |
| ":status": []string{"200"}, |
| }, |
| receiveDataHeader{size: 2}, |
| receiveData{size: 2}, |
| receiveDataHeader{size: 4}, |
| receiveData{size: 4}, |
| receiveDataHeader{size: 8}, |
| receiveData{size: 8}, |
| wantBody{size: 2}, |
| wantBody{size: 4}, |
| wantBody{size: 8}, |
| }, |
| }} { |
| runSynctestSubtest(t, test.name, func(t testing.TB) { |
| tc := newTestClientConn(t) |
| tc.greet() |
| |
| req, _ := http.NewRequest("GET", "https://example.tld/", nil) |
| rt := tc.roundTrip(req) |
| st := tc.wantStream(streamTypeRequest) |
| st.wantHeaders(nil) |
| |
| var ( |
| bytesSent int |
| bytesReceived int |
| ) |
| for _, step := range test.steps { |
| switch step := step.(type) { |
| case receiveHeaders: |
| st.writeHeaders(http.Header(step)) |
| case receiveDataHeader: |
| t.Logf("receive DATA frame header: size=%v", step.size) |
| st.writeVarint(int64(frameTypeData)) |
| st.writeVarint(step.size) |
| st.Flush() |
| case receiveData: |
| t.Logf("receive DATA frame content: size=%v", step.size) |
| for range step.size { |
| st.stream.stream.WriteByte(byte(bytesSent)) |
| bytesSent++ |
| } |
| st.Flush() |
| case receiveFrame: |
| st.writeVarint(int64(step.ftype)) |
| st.writeVarint(int64(len(step.data))) |
| st.Write(step.data) |
| st.Flush() |
| case receiveEOF: |
| t.Logf("receive EOF on request stream") |
| st.stream.stream.CloseWrite() |
| case wantBody: |
| t.Logf("read %v bytes from response body", step.size) |
| want := make([]byte, step.size) |
| for i := range want { |
| want[i] = byte(bytesReceived) |
| bytesReceived++ |
| } |
| got := make([]byte, step.size) |
| n, err := rt.response().Body.Read(got) |
| got = got[:n] |
| if !bytes.Equal(got, want) { |
| t.Errorf("resp.Body.Read:") |
| t.Errorf(" got: {%x}", got) |
| t.Fatalf(" want: {%x}", want) |
| } |
| if err != nil { |
| if step.eof && err == io.EOF { |
| continue |
| } |
| t.Fatalf("resp.Body.Read: unexpected error %v", err) |
| } |
| if step.eof { |
| if n, err := rt.response().Body.Read([]byte{0}); n != 0 || err != io.EOF { |
| t.Fatalf("resp.Body.Read() = %v, %v; want io.EOF", n, err) |
| } |
| } |
| case wantError: |
| if n, err := rt.response().Body.Read([]byte{0}); n != 0 || err == nil || err == io.EOF { |
| t.Fatalf("resp.Body.Read() = %v, %v; want error", n, err) |
| } |
| default: |
| t.Fatalf("unknown test step %T", step) |
| } |
| } |
| }) |
| } |
| } |
| |
| func TestRoundTripRequestBodySent(t *testing.T) { |
| runSynctest(t, func(t testing.TB) { |
| tc := newTestClientConn(t) |
| tc.greet() |
| |
| bodyr, bodyw := io.Pipe() |
| |
| req, _ := http.NewRequest("GET", "https://example.tld/", bodyr) |
| rt := tc.roundTrip(req) |
| st := tc.wantStream(streamTypeRequest) |
| st.wantHeaders(nil) |
| |
| bodyw.Write([]byte{0, 1, 2, 3, 4}) |
| st.wantData([]byte{0, 1, 2, 3, 4}) |
| |
| bodyw.Write([]byte{5, 6, 7}) |
| st.wantData([]byte{5, 6, 7}) |
| |
| bodyw.Close() |
| st.wantClosed("request body sent") |
| |
| st.writeHeaders(http.Header{ |
| ":status": []string{"200"}, |
| }) |
| rt.wantStatus(200) |
| rt.response().Body.Close() |
| }) |
| } |
| |
| func TestRoundTripRequestBodyErrors(t *testing.T) { |
| for _, test := range []struct { |
| name string |
| body io.Reader |
| contentLength int64 |
| }{{ |
| name: "too short", |
| contentLength: 10, |
| body: bytes.NewReader([]byte{0, 1, 2, 3, 4}), |
| }, { |
| name: "too long", |
| contentLength: 5, |
| body: bytes.NewReader([]byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}), |
| }, { |
| name: "read error", |
| body: io.MultiReader( |
| bytes.NewReader([]byte{0, 1, 2, 3, 4}), |
| &testReader{ |
| readFunc: func([]byte) (int, error) { |
| return 0, errors.New("read error") |
| }, |
| }, |
| ), |
| }} { |
| runSynctestSubtest(t, test.name, func(t testing.TB) { |
| tc := newTestClientConn(t) |
| tc.greet() |
| |
| req, _ := http.NewRequest("GET", "https://example.tld/", test.body) |
| req.ContentLength = test.contentLength |
| rt := tc.roundTrip(req) |
| st := tc.wantStream(streamTypeRequest) |
| |
| // The Transport should send some number of frames before detecting an |
| // error in the request body and aborting the request. |
| synctest.Wait() |
| for { |
| _, err := st.readFrameHeader() |
| if err != nil { |
| var code quic.StreamErrorCode |
| if !errors.As(err, &code) { |
| t.Fatalf("request stream closed with error %v: want QUIC stream error", err) |
| } |
| break |
| } |
| if err := st.discardFrame(); err != nil { |
| t.Fatalf("discardFrame: %v", err) |
| } |
| } |
| |
| // RoundTrip returns with an error. |
| rt.wantError("request fails due to body error") |
| }) |
| } |
| } |
| |
| func TestRoundTripRequestBodyErrorAfterHeaders(t *testing.T) { |
| runSynctest(t, func(t testing.TB) { |
| tc := newTestClientConn(t) |
| tc.greet() |
| |
| bodyr, bodyw := io.Pipe() |
| req, _ := http.NewRequest("GET", "https://example.tld/", bodyr) |
| req.ContentLength = 10 |
| rt := tc.roundTrip(req) |
| st := tc.wantStream(streamTypeRequest) |
| |
| // Server sends response headers, and RoundTrip returns. |
| // The request body hasn't been sent yet. |
| st.wantHeaders(nil) |
| st.writeHeaders(http.Header{ |
| ":status": []string{"200"}, |
| }) |
| rt.wantStatus(200) |
| |
| // Write too many bytes to the request body, triggering a request error. |
| bodyw.Write(make([]byte, req.ContentLength+1)) |
| |
| //io.Copy(io.Discard, st) |
| st.wantError(quic.StreamErrorCode(errH3InternalError)) |
| |
| if err := rt.response().Body.Close(); err == nil { |
| t.Fatalf("Response.Body.Close() = %v, want error", err) |
| } |
| }) |
| } |