blob: 533b750a55188ace1e741fed1a822ae0f50bd7d6 [file] [log] [blame]
// 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)
}
})
}