355 lines
10 KiB
Go
355 lines
10 KiB
Go
|
package archival
|
||
|
|
||
|
import (
|
||
|
"bytes"
|
||
|
"context"
|
||
|
"errors"
|
||
|
"fmt"
|
||
|
"io"
|
||
|
"net/http"
|
||
|
"net/url"
|
||
|
"testing"
|
||
|
|
||
|
"github.com/google/go-cmp/cmp"
|
||
|
"github.com/ooni/probe-cli/v3/internal/model"
|
||
|
"github.com/ooni/probe-cli/v3/internal/model/mocks"
|
||
|
"github.com/ooni/probe-cli/v3/internal/netxlite"
|
||
|
"github.com/ooni/probe-cli/v3/internal/runtimex"
|
||
|
)
|
||
|
|
||
|
func TestSaverHTTPRoundTrip(t *testing.T) {
|
||
|
// newHTTPTransport creates a new HTTP transport for testing.
|
||
|
newHTTPTransport := func(resp *http.Response, err error) model.HTTPTransport {
|
||
|
return &mocks.HTTPTransport{
|
||
|
MockRoundTrip: func(req *http.Request) (*http.Response, error) {
|
||
|
return resp, err
|
||
|
},
|
||
|
MockNetwork: func() string {
|
||
|
return "tcp"
|
||
|
},
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// successFlowWithBody is a successful test case with possible body truncation.
|
||
|
successFlowWithBody := func(realBody []byte, maxBodySize int64) error {
|
||
|
// truncate the expected body if required
|
||
|
expectedBody := realBody
|
||
|
truncated := false
|
||
|
if int64(len(realBody)) > maxBodySize {
|
||
|
expectedBody = realBody[:maxBodySize]
|
||
|
truncated = true
|
||
|
}
|
||
|
// construct the saver and the validator
|
||
|
saver := NewSaver()
|
||
|
v := &SingleHTTPRoundTripValidator{
|
||
|
ExpectFailure: nil,
|
||
|
ExpectMethod: "GET",
|
||
|
ExpectRequestHeaders: map[string][]string{
|
||
|
"Host": {"127.0.0.1:8080"},
|
||
|
"User-Agent": {"antani/1.0"},
|
||
|
"X-Client-IP": {"130.192.91.211"},
|
||
|
},
|
||
|
ExpectResponseBody: expectedBody,
|
||
|
ExpectResponseBodyIsTruncated: truncated,
|
||
|
ExpectResponseBodyLength: int64(len(expectedBody)),
|
||
|
ExpectResponseHeaders: map[string][]string{
|
||
|
"Server": {"antani/1.0"},
|
||
|
"Content-Type": {"text/plain"},
|
||
|
},
|
||
|
ExpectStatusCode: 200,
|
||
|
ExpectTransport: "tcp",
|
||
|
ExpectURL: "http://127.0.0.1:8080/antani",
|
||
|
RealResponseBody: realBody,
|
||
|
Saver: saver,
|
||
|
}
|
||
|
// construct transport and perform the HTTP round trip
|
||
|
txp := newHTTPTransport(v.NewHTTPResponse(), nil)
|
||
|
resp, err := saver.HTTPRoundTrip(txp, maxBodySize, v.NewHTTPRequest())
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
if resp == nil {
|
||
|
return errors.New("expected non-nil resp")
|
||
|
}
|
||
|
// ensure that we can still read the _full_ response body
|
||
|
ctx := context.Background()
|
||
|
data, err := netxlite.ReadAllContext(ctx, resp.Body)
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
if diff := cmp.Diff(realBody, data); diff != "" {
|
||
|
return errors.New(diff)
|
||
|
}
|
||
|
// validate the content of the trace
|
||
|
return v.Validate()
|
||
|
}
|
||
|
|
||
|
t.Run("on success without truncation", func(t *testing.T) {
|
||
|
realBody := []byte("0xdeadbeef")
|
||
|
const maxBodySize = 1 << 20
|
||
|
err := successFlowWithBody(realBody, maxBodySize)
|
||
|
if err != nil {
|
||
|
t.Fatal(err)
|
||
|
}
|
||
|
})
|
||
|
|
||
|
t.Run("on success with truncation", func(t *testing.T) {
|
||
|
realBody := []byte("0xdeadbeef")
|
||
|
const maxBodySize = 4
|
||
|
err := successFlowWithBody(realBody, maxBodySize)
|
||
|
if err != nil {
|
||
|
t.Fatal(err)
|
||
|
}
|
||
|
})
|
||
|
|
||
|
t.Run("on failure during round trip", func(t *testing.T) {
|
||
|
expectedError := netxlite.NewTopLevelGenericErrWrapper(netxlite.ECONNRESET)
|
||
|
const maxBodySize = 1 << 20
|
||
|
saver := NewSaver()
|
||
|
v := &SingleHTTPRoundTripValidator{
|
||
|
ExpectFailure: expectedError,
|
||
|
ExpectMethod: "GET",
|
||
|
ExpectRequestHeaders: map[string][]string{
|
||
|
"Host": {"127.0.0.1:8080"},
|
||
|
"User-Agent": {"antani/1.0"},
|
||
|
"X-Client-IP": {"130.192.91.211"},
|
||
|
},
|
||
|
ExpectResponseBody: nil,
|
||
|
ExpectResponseBodyIsTruncated: false,
|
||
|
ExpectResponseBodyLength: 0,
|
||
|
ExpectResponseHeaders: nil,
|
||
|
ExpectStatusCode: 0,
|
||
|
ExpectTransport: "tcp",
|
||
|
ExpectURL: "http://127.0.0.1:8080/antani",
|
||
|
RealResponseBody: nil,
|
||
|
Saver: saver,
|
||
|
}
|
||
|
txp := newHTTPTransport(nil, expectedError)
|
||
|
resp, err := saver.HTTPRoundTrip(txp, maxBodySize, v.NewHTTPRequest())
|
||
|
if !errors.Is(err, expectedError) {
|
||
|
t.Fatal(err)
|
||
|
}
|
||
|
if resp != nil {
|
||
|
t.Fatal("expected nil resp")
|
||
|
}
|
||
|
if err := v.Validate(); err != nil {
|
||
|
t.Fatal(err)
|
||
|
}
|
||
|
})
|
||
|
|
||
|
t.Run("on failure reading body", func(t *testing.T) {
|
||
|
expectedError := netxlite.NewTopLevelGenericErrWrapper(netxlite.ECONNRESET)
|
||
|
const maxBodySize = 1 << 20
|
||
|
saver := NewSaver()
|
||
|
v := &SingleHTTPRoundTripValidator{
|
||
|
ExpectFailure: expectedError,
|
||
|
ExpectMethod: "GET",
|
||
|
ExpectRequestHeaders: map[string][]string{
|
||
|
"Host": {"127.0.0.1:8080"},
|
||
|
"User-Agent": {"antani/1.0"},
|
||
|
"X-Client-IP": {"130.192.91.211"},
|
||
|
},
|
||
|
ExpectResponseBody: nil,
|
||
|
ExpectResponseBodyIsTruncated: false,
|
||
|
ExpectResponseBodyLength: 0,
|
||
|
ExpectResponseHeaders: map[string][]string{
|
||
|
"Server": {"antani/1.0"},
|
||
|
"Content-Type": {"text/plain"},
|
||
|
},
|
||
|
ExpectStatusCode: 200,
|
||
|
ExpectTransport: "tcp",
|
||
|
ExpectURL: "http://127.0.0.1:8080/antani",
|
||
|
RealResponseBody: nil,
|
||
|
Saver: saver,
|
||
|
}
|
||
|
resp := v.NewHTTPResponse()
|
||
|
// Hack the body so it returns a connection reset error
|
||
|
// after some useful piece of data. We do not see any
|
||
|
// body in the response or in the trace. We may possibly
|
||
|
// want to include all the body we could read into the
|
||
|
// trace in the future, but for now it seems fine to do
|
||
|
// exactly what the previous code was doing.
|
||
|
resp.Body = io.NopCloser(io.MultiReader(
|
||
|
bytes.NewReader([]byte("0xdeadbeef")),
|
||
|
&mocks.Reader{
|
||
|
MockRead: func(b []byte) (int, error) {
|
||
|
return 0, expectedError
|
||
|
},
|
||
|
},
|
||
|
))
|
||
|
txp := newHTTPTransport(resp, nil)
|
||
|
resp, err := saver.HTTPRoundTrip(txp, maxBodySize, v.NewHTTPRequest())
|
||
|
if !errors.Is(err, expectedError) {
|
||
|
t.Fatal("unexpected err", err)
|
||
|
}
|
||
|
if resp != nil {
|
||
|
t.Fatal("expected nil resp")
|
||
|
}
|
||
|
if err := v.Validate(); err != nil {
|
||
|
t.Fatal(err)
|
||
|
}
|
||
|
})
|
||
|
|
||
|
t.Run("cloneRequestHeaders", func(t *testing.T) {
|
||
|
// doWithRequest is an helper function that creates a suitable
|
||
|
// round tripper and returns trace.HTTPRoundTrip[0] for inspection
|
||
|
doWithRequest := func(req *http.Request) (*HTTPRoundTripEvent, error) {
|
||
|
expect := errors.New("mocked err")
|
||
|
txp := newHTTPTransport(nil, expect)
|
||
|
saver := NewSaver()
|
||
|
const maxBodySize = 1 << 20 // irrelevant
|
||
|
resp, err := saver.HTTPRoundTrip(txp, maxBodySize, req)
|
||
|
if !errors.Is(err, expect) {
|
||
|
return nil, fmt.Errorf("unexpected error: %w", err)
|
||
|
}
|
||
|
if resp != nil {
|
||
|
return nil, errors.New("expected nil resp")
|
||
|
}
|
||
|
trace := saver.MoveOutTrace()
|
||
|
if len(trace.HTTPRoundTrip) != 1 {
|
||
|
return nil, errors.New("expected exactly one HTTPRoundTrip")
|
||
|
}
|
||
|
return trace.HTTPRoundTrip[0], nil
|
||
|
}
|
||
|
|
||
|
t.Run("with req.URL.Host", func(t *testing.T) {
|
||
|
req, err := http.NewRequest("GET", "https://x.org/", nil)
|
||
|
if err != nil {
|
||
|
t.Fatal(err)
|
||
|
}
|
||
|
ev, err := doWithRequest(req)
|
||
|
if err != nil {
|
||
|
t.Fatal(err)
|
||
|
}
|
||
|
if ev.RequestHeaders.Get("Host") != "x.org" {
|
||
|
t.Fatal("unexpected request host")
|
||
|
}
|
||
|
})
|
||
|
|
||
|
t.Run("with req.Host", func(t *testing.T) {
|
||
|
req, err := http.NewRequest("GET", "https://x.org/", nil)
|
||
|
if err != nil {
|
||
|
t.Fatal(err)
|
||
|
}
|
||
|
req.Host = "google.com"
|
||
|
ev, err := doWithRequest(req)
|
||
|
if err != nil {
|
||
|
t.Fatal(err)
|
||
|
}
|
||
|
if ev.RequestHeaders.Get("Host") != "google.com" {
|
||
|
t.Fatal("unexpected request host")
|
||
|
}
|
||
|
})
|
||
|
})
|
||
|
}
|
||
|
|
||
|
type SingleHTTPRoundTripValidator struct {
|
||
|
ExpectFailure error
|
||
|
ExpectMethod string
|
||
|
ExpectRequestHeaders http.Header
|
||
|
ExpectResponseBody []byte
|
||
|
ExpectResponseBodyIsTruncated bool
|
||
|
ExpectResponseBodyLength int64
|
||
|
ExpectResponseHeaders http.Header
|
||
|
ExpectStatusCode int64
|
||
|
ExpectTransport string
|
||
|
ExpectURL string
|
||
|
RealResponseBody []byte
|
||
|
Saver *Saver
|
||
|
}
|
||
|
|
||
|
func (v *SingleHTTPRoundTripValidator) NewHTTPRequest() *http.Request {
|
||
|
parsedURL, err := url.Parse(v.ExpectURL)
|
||
|
runtimex.PanicOnError(err, "url.Parse should not fail here")
|
||
|
// The saving code clones the headers and adds the host header, which
|
||
|
// Go would instead add later. So, a realistic mock should not include
|
||
|
// such an header inside of the http.Request.
|
||
|
clonedHeaders := v.ExpectRequestHeaders.Clone()
|
||
|
clonedHeaders.Del("Host")
|
||
|
return &http.Request{
|
||
|
Method: v.ExpectMethod,
|
||
|
URL: parsedURL,
|
||
|
Proto: "",
|
||
|
ProtoMajor: 0,
|
||
|
ProtoMinor: 0,
|
||
|
Header: clonedHeaders,
|
||
|
Body: nil,
|
||
|
GetBody: nil,
|
||
|
ContentLength: 0,
|
||
|
TransferEncoding: nil,
|
||
|
Close: false,
|
||
|
Host: "",
|
||
|
Form: nil,
|
||
|
PostForm: nil,
|
||
|
MultipartForm: nil,
|
||
|
Trailer: nil,
|
||
|
RemoteAddr: "",
|
||
|
RequestURI: "",
|
||
|
TLS: nil,
|
||
|
Cancel: nil,
|
||
|
Response: nil,
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func (v *SingleHTTPRoundTripValidator) NewHTTPResponse() *http.Response {
|
||
|
body := io.NopCloser(bytes.NewReader(v.RealResponseBody))
|
||
|
return &http.Response{
|
||
|
Status: http.StatusText(int(v.ExpectStatusCode)),
|
||
|
StatusCode: int(v.ExpectStatusCode),
|
||
|
Proto: "",
|
||
|
ProtoMajor: 0,
|
||
|
ProtoMinor: 0,
|
||
|
Header: v.ExpectResponseHeaders,
|
||
|
Body: body,
|
||
|
ContentLength: 0,
|
||
|
TransferEncoding: nil,
|
||
|
Close: false,
|
||
|
Uncompressed: false,
|
||
|
Trailer: nil,
|
||
|
Request: nil,
|
||
|
TLS: nil,
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func (v *SingleHTTPRoundTripValidator) Validate() error {
|
||
|
trace := v.Saver.MoveOutTrace()
|
||
|
if len(trace.HTTPRoundTrip) != 1 {
|
||
|
return errors.New("expected to see one event")
|
||
|
}
|
||
|
entry := trace.HTTPRoundTrip[0]
|
||
|
if !errors.Is(entry.Failure, v.ExpectFailure) {
|
||
|
return errors.New("unexpected .Failure")
|
||
|
}
|
||
|
if !entry.Finished.After(entry.Started) {
|
||
|
return errors.New(".Finished is not after .Started")
|
||
|
}
|
||
|
if entry.Method != v.ExpectMethod {
|
||
|
return errors.New("unexpected .Method")
|
||
|
}
|
||
|
if diff := cmp.Diff(v.ExpectRequestHeaders, entry.RequestHeaders); diff != "" {
|
||
|
return errors.New(diff)
|
||
|
}
|
||
|
if diff := cmp.Diff(v.ExpectResponseBody, entry.ResponseBody); diff != "" {
|
||
|
return errors.New(diff)
|
||
|
}
|
||
|
if entry.ResponseBodyIsTruncated != v.ExpectResponseBodyIsTruncated {
|
||
|
return errors.New("unexpected .ResponseBodyIsTruncated")
|
||
|
}
|
||
|
if entry.ResponseBodyLength != v.ExpectResponseBodyLength {
|
||
|
return errors.New("unexpected .ResponseBodyLength")
|
||
|
}
|
||
|
if diff := cmp.Diff(v.ExpectResponseHeaders, entry.ResponseHeaders); diff != "" {
|
||
|
return errors.New(diff)
|
||
|
}
|
||
|
if entry.StatusCode != v.ExpectStatusCode {
|
||
|
return errors.New("unexpected .StatusCode")
|
||
|
}
|
||
|
if entry.Transport != v.ExpectTransport {
|
||
|
return errors.New("unexpected .Transport")
|
||
|
}
|
||
|
if entry.URL != v.ExpectURL {
|
||
|
return errors.New("unexpected .URL")
|
||
|
}
|
||
|
return nil
|
||
|
}
|