1164 lines
29 KiB
Go
1164 lines
29 KiB
Go
|
package httpapi
|
||
|
|
||
|
import (
|
||
|
"context"
|
||
|
"errors"
|
||
|
"fmt"
|
||
|
"io"
|
||
|
"net/http"
|
||
|
"net/http/httptest"
|
||
|
"net/url"
|
||
|
"strings"
|
||
|
"syscall"
|
||
|
"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 Test_joinURLPath(t *testing.T) {
|
||
|
tests := []struct {
|
||
|
name string
|
||
|
urlPath string
|
||
|
resourcePath string
|
||
|
want string
|
||
|
}{{
|
||
|
name: "whole path inside urlPath and empty resourcePath",
|
||
|
urlPath: "/robots.txt",
|
||
|
resourcePath: "",
|
||
|
want: "/robots.txt",
|
||
|
}, {
|
||
|
name: "empty urlPath and slash-prefixed resourcePath",
|
||
|
urlPath: "",
|
||
|
resourcePath: "/foo",
|
||
|
want: "/foo",
|
||
|
}, {
|
||
|
name: "slash urlPath and slash-prefixed resourcePath",
|
||
|
urlPath: "/",
|
||
|
resourcePath: "/foo",
|
||
|
want: "/foo",
|
||
|
}, {
|
||
|
name: "empty urlPath and empty resourcePath",
|
||
|
urlPath: "",
|
||
|
resourcePath: "",
|
||
|
want: "/",
|
||
|
}, {
|
||
|
name: "non-slash-terminated urlPath and slash-prefixed resourcePath",
|
||
|
urlPath: "/foo",
|
||
|
resourcePath: "/bar",
|
||
|
want: "/foo/bar",
|
||
|
}, {
|
||
|
name: "slash-terminated urlPath and slash-prefixed resourcePath",
|
||
|
urlPath: "/foo/",
|
||
|
resourcePath: "/bar",
|
||
|
want: "/foo/bar",
|
||
|
}, {
|
||
|
name: "slash-terminated urlPath and non-slash-prefixed resourcePath",
|
||
|
urlPath: "/foo",
|
||
|
resourcePath: "bar",
|
||
|
want: "/foo/bar",
|
||
|
}}
|
||
|
for _, tt := range tests {
|
||
|
t.Run(tt.name, func(t *testing.T) {
|
||
|
got := joinURLPath(tt.urlPath, tt.resourcePath)
|
||
|
if diff := cmp.Diff(tt.want, got); diff != "" {
|
||
|
t.Fatal(diff)
|
||
|
}
|
||
|
})
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func Test_newRequest(t *testing.T) {
|
||
|
type args struct {
|
||
|
ctx context.Context
|
||
|
endpoint *Endpoint
|
||
|
desc *Descriptor
|
||
|
}
|
||
|
tests := []struct {
|
||
|
name string
|
||
|
args args
|
||
|
wantFn func(*testing.T, *http.Request)
|
||
|
wantErr error
|
||
|
}{{
|
||
|
name: "url.Parse fails",
|
||
|
args: args{
|
||
|
ctx: nil,
|
||
|
endpoint: &Endpoint{
|
||
|
BaseURL: "\t\t\t", // does not parse!
|
||
|
HTTPClient: nil,
|
||
|
Host: "",
|
||
|
UserAgent: "",
|
||
|
},
|
||
|
desc: &Descriptor{
|
||
|
Accept: "",
|
||
|
Authorization: "",
|
||
|
ContentType: "",
|
||
|
LogBody: false,
|
||
|
Logger: nil,
|
||
|
MaxBodySize: 0,
|
||
|
Method: "",
|
||
|
RequestBody: nil,
|
||
|
Timeout: 0,
|
||
|
URLPath: "",
|
||
|
URLQuery: nil,
|
||
|
},
|
||
|
},
|
||
|
wantFn: nil,
|
||
|
wantErr: errors.New(`parse "\t\t\t": net/url: invalid control character in URL`),
|
||
|
}, {
|
||
|
name: "http.NewRequestWithContext fails",
|
||
|
args: args{
|
||
|
ctx: nil, // causes http.NewRequestWithContext to fail
|
||
|
endpoint: &Endpoint{
|
||
|
BaseURL: "https://example.com/",
|
||
|
HTTPClient: nil,
|
||
|
Host: "",
|
||
|
UserAgent: "",
|
||
|
},
|
||
|
desc: &Descriptor{
|
||
|
Accept: "",
|
||
|
Authorization: "",
|
||
|
ContentType: "",
|
||
|
LogBody: false,
|
||
|
Logger: nil,
|
||
|
MaxBodySize: 0,
|
||
|
Method: "",
|
||
|
RequestBody: nil,
|
||
|
Timeout: 0,
|
||
|
URLPath: "",
|
||
|
URLQuery: nil,
|
||
|
},
|
||
|
},
|
||
|
wantFn: nil,
|
||
|
wantErr: errors.New("net/http: nil Context"),
|
||
|
}, {
|
||
|
name: "successful case with GET method, no body, and no extra headers",
|
||
|
args: args{
|
||
|
ctx: context.Background(),
|
||
|
endpoint: &Endpoint{
|
||
|
BaseURL: "https://example.com/",
|
||
|
HTTPClient: nil,
|
||
|
Host: "",
|
||
|
UserAgent: "",
|
||
|
},
|
||
|
desc: &Descriptor{
|
||
|
Accept: "",
|
||
|
Authorization: "",
|
||
|
ContentType: "",
|
||
|
LogBody: false,
|
||
|
Logger: nil,
|
||
|
MaxBodySize: 0,
|
||
|
Method: http.MethodGet,
|
||
|
RequestBody: nil,
|
||
|
Timeout: 0,
|
||
|
URLPath: "",
|
||
|
URLQuery: nil,
|
||
|
},
|
||
|
},
|
||
|
wantFn: func(t *testing.T, req *http.Request) {
|
||
|
if req == nil {
|
||
|
t.Fatal("expected non-nil request")
|
||
|
}
|
||
|
if req.Method != http.MethodGet {
|
||
|
t.Fatal("invalid method")
|
||
|
}
|
||
|
if req.URL.String() != "https://example.com/" {
|
||
|
t.Fatal("invalid URL")
|
||
|
}
|
||
|
if req.Body != nil {
|
||
|
t.Fatal("invalid body", req.Body)
|
||
|
}
|
||
|
},
|
||
|
wantErr: nil,
|
||
|
}, {
|
||
|
name: "successful case with POST method and body",
|
||
|
args: args{
|
||
|
ctx: context.Background(),
|
||
|
endpoint: &Endpoint{
|
||
|
BaseURL: "https://example.com/",
|
||
|
HTTPClient: nil,
|
||
|
Host: "",
|
||
|
UserAgent: "",
|
||
|
},
|
||
|
desc: &Descriptor{
|
||
|
Accept: "",
|
||
|
Authorization: "",
|
||
|
ContentType: "",
|
||
|
LogBody: false,
|
||
|
Logger: model.DiscardLogger,
|
||
|
MaxBodySize: 0,
|
||
|
Method: http.MethodPost,
|
||
|
RequestBody: []byte("deadbeef"),
|
||
|
Timeout: 0,
|
||
|
URLPath: "",
|
||
|
URLQuery: nil,
|
||
|
},
|
||
|
},
|
||
|
wantFn: func(t *testing.T, req *http.Request) {
|
||
|
if req == nil {
|
||
|
t.Fatal("expected non-nil request")
|
||
|
}
|
||
|
if req.Method != http.MethodPost {
|
||
|
t.Fatal("invalid method")
|
||
|
}
|
||
|
if req.URL.String() != "https://example.com/" {
|
||
|
t.Fatal("invalid URL")
|
||
|
}
|
||
|
data, err := netxlite.ReadAllContext(context.Background(), req.Body)
|
||
|
if err != nil {
|
||
|
t.Fatal(err)
|
||
|
}
|
||
|
if diff := cmp.Diff([]byte("deadbeef"), data); diff != "" {
|
||
|
t.Fatal(diff)
|
||
|
}
|
||
|
},
|
||
|
wantErr: nil,
|
||
|
}, {
|
||
|
name: "with GET method and custom headers",
|
||
|
args: args{
|
||
|
ctx: context.Background(),
|
||
|
endpoint: &Endpoint{
|
||
|
BaseURL: "https://example.com/",
|
||
|
HTTPClient: nil,
|
||
|
Host: "antani.org",
|
||
|
UserAgent: "httpclient/1.0.1",
|
||
|
},
|
||
|
desc: &Descriptor{
|
||
|
Accept: "application/json",
|
||
|
Authorization: "deafbeef",
|
||
|
ContentType: "text/plain",
|
||
|
LogBody: false,
|
||
|
Logger: nil,
|
||
|
MaxBodySize: 0,
|
||
|
Method: http.MethodPut,
|
||
|
RequestBody: nil,
|
||
|
Timeout: 0,
|
||
|
URLPath: "",
|
||
|
URLQuery: nil,
|
||
|
},
|
||
|
},
|
||
|
wantFn: func(t *testing.T, req *http.Request) {
|
||
|
if req == nil {
|
||
|
t.Fatal("expected non-nil request")
|
||
|
}
|
||
|
if req.Method != http.MethodPut {
|
||
|
t.Fatal("invalid method")
|
||
|
}
|
||
|
if req.Host != "antani.org" {
|
||
|
t.Fatal("invalid request host")
|
||
|
}
|
||
|
if req.URL.String() != "https://example.com/" {
|
||
|
t.Fatal("invalid URL")
|
||
|
}
|
||
|
if req.Header.Get("Authorization") != "deafbeef" {
|
||
|
t.Fatal("invalid authorization")
|
||
|
}
|
||
|
if req.Header.Get("Content-Type") != "text/plain" {
|
||
|
t.Fatal("invalid content-type")
|
||
|
}
|
||
|
if req.Header.Get("Accept") != "application/json" {
|
||
|
t.Fatal("invalid accept")
|
||
|
}
|
||
|
if req.Header.Get("User-Agent") != "httpclient/1.0.1" {
|
||
|
t.Fatal("invalid user-agent")
|
||
|
}
|
||
|
},
|
||
|
wantErr: nil,
|
||
|
}, {
|
||
|
name: "we join the urlPath with the resourcePath",
|
||
|
args: args{
|
||
|
ctx: context.Background(),
|
||
|
endpoint: &Endpoint{
|
||
|
BaseURL: "https://www.example.com/api/v1",
|
||
|
HTTPClient: nil,
|
||
|
Host: "",
|
||
|
UserAgent: "",
|
||
|
},
|
||
|
desc: &Descriptor{
|
||
|
Accept: "",
|
||
|
Authorization: "",
|
||
|
ContentType: "",
|
||
|
LogBody: false,
|
||
|
Logger: nil,
|
||
|
MaxBodySize: 0,
|
||
|
Method: http.MethodGet,
|
||
|
RequestBody: nil,
|
||
|
Timeout: 0,
|
||
|
URLPath: "/test-list/urls",
|
||
|
URLQuery: nil,
|
||
|
},
|
||
|
},
|
||
|
wantFn: func(t *testing.T, req *http.Request) {
|
||
|
if req == nil {
|
||
|
t.Fatal("expected non-nil request")
|
||
|
}
|
||
|
if req.Method != http.MethodGet {
|
||
|
t.Fatal("invalid method")
|
||
|
}
|
||
|
if req.URL.String() != "https://www.example.com/api/v1/test-list/urls" {
|
||
|
t.Fatal("invalid URL")
|
||
|
}
|
||
|
},
|
||
|
wantErr: nil,
|
||
|
}, {
|
||
|
name: "we discard any query element inside the Endpoint.BaseURL",
|
||
|
args: args{
|
||
|
ctx: context.Background(),
|
||
|
endpoint: &Endpoint{
|
||
|
BaseURL: "https://example.org/api/v1/?probe_cc=IT",
|
||
|
HTTPClient: nil,
|
||
|
Host: "",
|
||
|
UserAgent: "",
|
||
|
},
|
||
|
desc: &Descriptor{
|
||
|
Accept: "",
|
||
|
Authorization: "",
|
||
|
ContentType: "",
|
||
|
LogBody: false,
|
||
|
Logger: nil,
|
||
|
MaxBodySize: 0,
|
||
|
Method: http.MethodGet,
|
||
|
RequestBody: nil,
|
||
|
Timeout: 0,
|
||
|
URLPath: "",
|
||
|
URLQuery: nil,
|
||
|
},
|
||
|
},
|
||
|
wantFn: func(t *testing.T, req *http.Request) {
|
||
|
if req == nil {
|
||
|
t.Fatal("expected non-nil request")
|
||
|
}
|
||
|
if req.Method != http.MethodGet {
|
||
|
t.Fatal("invalid method")
|
||
|
}
|
||
|
if req.URL.String() != "https://example.org/api/v1/" {
|
||
|
t.Fatal("invalid URL")
|
||
|
}
|
||
|
},
|
||
|
wantErr: nil,
|
||
|
}, {
|
||
|
name: "we include query elements from Descriptor.URLQuery",
|
||
|
args: args{
|
||
|
ctx: context.Background(),
|
||
|
endpoint: &Endpoint{
|
||
|
BaseURL: "https://www.example.com/api/v1/",
|
||
|
HTTPClient: nil,
|
||
|
Host: "",
|
||
|
UserAgent: "",
|
||
|
},
|
||
|
desc: &Descriptor{
|
||
|
Accept: "",
|
||
|
Authorization: "",
|
||
|
ContentType: "",
|
||
|
LogBody: false,
|
||
|
Logger: nil,
|
||
|
MaxBodySize: 0,
|
||
|
Method: http.MethodGet,
|
||
|
RequestBody: nil,
|
||
|
Timeout: 0,
|
||
|
URLPath: "test-list/urls",
|
||
|
URLQuery: map[string][]string{
|
||
|
"probe_cc": {"IT"},
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
wantFn: func(t *testing.T, req *http.Request) {
|
||
|
if req == nil {
|
||
|
t.Fatal("expected non-nil request")
|
||
|
}
|
||
|
if req.Method != http.MethodGet {
|
||
|
t.Fatal("invalid method")
|
||
|
}
|
||
|
if req.URL.String() != "https://www.example.com/api/v1/test-list/urls?probe_cc=IT" {
|
||
|
t.Fatal("invalid URL")
|
||
|
}
|
||
|
},
|
||
|
wantErr: nil,
|
||
|
}, {
|
||
|
name: "with as many implicitly-initialized fields as possible",
|
||
|
args: args{
|
||
|
ctx: context.Background(),
|
||
|
endpoint: &Endpoint{
|
||
|
BaseURL: "https://example.com/",
|
||
|
},
|
||
|
desc: &Descriptor{},
|
||
|
},
|
||
|
wantFn: func(t *testing.T, req *http.Request) {
|
||
|
if req == nil {
|
||
|
t.Fatal("expected non-nil request")
|
||
|
}
|
||
|
if req.Method != http.MethodGet {
|
||
|
t.Fatal("invalid method")
|
||
|
}
|
||
|
if req.URL.String() != "https://example.com/" {
|
||
|
t.Fatal("invalid URL")
|
||
|
}
|
||
|
},
|
||
|
wantErr: nil,
|
||
|
}}
|
||
|
for _, tt := range tests {
|
||
|
t.Run(tt.name, func(t *testing.T) {
|
||
|
got, err := newRequest(tt.args.ctx, tt.args.endpoint, tt.args.desc)
|
||
|
switch {
|
||
|
case err == nil && tt.wantErr == nil:
|
||
|
// nothing
|
||
|
case err != nil && tt.wantErr == nil:
|
||
|
t.Fatalf("expected <nil> error but got %s", err.Error())
|
||
|
case err == nil && tt.wantErr != nil:
|
||
|
t.Fatalf("expected %s but got <nil>", tt.wantErr.Error())
|
||
|
case err.Error() == tt.wantErr.Error():
|
||
|
// nothing
|
||
|
default:
|
||
|
t.Fatalf("expected %s but got %s", err.Error(), tt.wantErr.Error())
|
||
|
}
|
||
|
if tt.wantFn != nil {
|
||
|
tt.wantFn(t, got)
|
||
|
return
|
||
|
}
|
||
|
if got != nil {
|
||
|
t.Fatal("got response with nil tt.wantFn")
|
||
|
}
|
||
|
})
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func TestCall(t *testing.T) {
|
||
|
type args struct {
|
||
|
ctx context.Context
|
||
|
desc *Descriptor
|
||
|
endpoint *Endpoint
|
||
|
}
|
||
|
tests := []struct {
|
||
|
name string
|
||
|
args args
|
||
|
want []byte
|
||
|
wantErr error
|
||
|
errfn func(t *testing.T, err error)
|
||
|
}{{
|
||
|
name: "newRequest fails",
|
||
|
args: args{
|
||
|
ctx: context.Background(),
|
||
|
desc: &Descriptor{
|
||
|
Accept: "",
|
||
|
Authorization: "",
|
||
|
ContentType: "",
|
||
|
LogBody: false,
|
||
|
Logger: nil,
|
||
|
MaxBodySize: 0,
|
||
|
Method: "",
|
||
|
RequestBody: nil,
|
||
|
Timeout: 0,
|
||
|
URLPath: "",
|
||
|
URLQuery: nil,
|
||
|
},
|
||
|
endpoint: &Endpoint{
|
||
|
BaseURL: "\t\t\t", // causes newRequest to fail
|
||
|
HTTPClient: nil,
|
||
|
Host: "",
|
||
|
UserAgent: "",
|
||
|
},
|
||
|
},
|
||
|
want: nil,
|
||
|
wantErr: errors.New(`parse "\t\t\t": net/url: invalid control character in URL`),
|
||
|
errfn: nil,
|
||
|
}, {
|
||
|
name: "endpoint.HTTPClient.Do fails",
|
||
|
args: args{
|
||
|
ctx: context.Background(),
|
||
|
desc: &Descriptor{
|
||
|
Logger: model.DiscardLogger,
|
||
|
Method: http.MethodGet,
|
||
|
},
|
||
|
endpoint: &Endpoint{
|
||
|
BaseURL: "https://example.com/",
|
||
|
HTTPClient: &mocks.HTTPClient{
|
||
|
MockDo: func(req *http.Request) (*http.Response, error) {
|
||
|
return nil, io.EOF
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
want: nil,
|
||
|
wantErr: io.EOF,
|
||
|
errfn: func(t *testing.T, err error) {
|
||
|
var expect *errMaybeCensorship
|
||
|
if !errors.As(err, &expect) {
|
||
|
t.Fatal("unexpected error type")
|
||
|
}
|
||
|
},
|
||
|
}, {
|
||
|
name: "reading body fails",
|
||
|
args: args{
|
||
|
ctx: context.Background(),
|
||
|
desc: &Descriptor{
|
||
|
Logger: model.DiscardLogger,
|
||
|
Method: http.MethodGet,
|
||
|
},
|
||
|
endpoint: &Endpoint{
|
||
|
BaseURL: "https://www.example.com/",
|
||
|
HTTPClient: &mocks.HTTPClient{
|
||
|
MockDo: func(req *http.Request) (*http.Response, error) {
|
||
|
resp := &http.Response{
|
||
|
Body: io.NopCloser(&mocks.Reader{
|
||
|
MockRead: func(b []byte) (int, error) {
|
||
|
return 0, netxlite.ECONNRESET
|
||
|
},
|
||
|
}),
|
||
|
}
|
||
|
return resp, nil
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
want: nil,
|
||
|
wantErr: errors.New(netxlite.FailureConnectionReset),
|
||
|
errfn: func(t *testing.T, err error) {
|
||
|
var expect *errMaybeCensorship
|
||
|
if !errors.As(err, &expect) {
|
||
|
t.Fatal("unexpected error type")
|
||
|
}
|
||
|
},
|
||
|
}, {
|
||
|
name: "status code indicates failure",
|
||
|
args: args{
|
||
|
ctx: context.Background(),
|
||
|
desc: &Descriptor{
|
||
|
Logger: model.DiscardLogger,
|
||
|
Method: http.MethodGet,
|
||
|
},
|
||
|
endpoint: &Endpoint{
|
||
|
BaseURL: "https://example.com/",
|
||
|
HTTPClient: &mocks.HTTPClient{
|
||
|
MockDo: func(req *http.Request) (*http.Response, error) {
|
||
|
resp := &http.Response{
|
||
|
Body: io.NopCloser(strings.NewReader("deadbeef")),
|
||
|
StatusCode: 403,
|
||
|
}
|
||
|
return resp, nil
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
want: nil,
|
||
|
wantErr: errors.New("httpapi: http request failed: 403"),
|
||
|
errfn: func(t *testing.T, err error) {
|
||
|
var expect *ErrHTTPRequestFailed
|
||
|
if !errors.As(err, &expect) {
|
||
|
t.Fatal("invalid error type")
|
||
|
}
|
||
|
},
|
||
|
}, {
|
||
|
name: "success with log body flag",
|
||
|
args: args{
|
||
|
ctx: context.Background(),
|
||
|
desc: &Descriptor{
|
||
|
LogBody: true, // as documented by this test's name
|
||
|
Logger: model.DiscardLogger,
|
||
|
Method: http.MethodGet,
|
||
|
},
|
||
|
endpoint: &Endpoint{
|
||
|
BaseURL: "https://example.com/",
|
||
|
HTTPClient: &mocks.HTTPClient{
|
||
|
MockDo: func(req *http.Request) (*http.Response, error) {
|
||
|
resp := &http.Response{
|
||
|
Body: io.NopCloser(strings.NewReader("deadbeef")),
|
||
|
StatusCode: 200,
|
||
|
}
|
||
|
return resp, nil
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
want: []byte("deadbeef"),
|
||
|
wantErr: nil,
|
||
|
errfn: nil,
|
||
|
}}
|
||
|
for _, tt := range tests {
|
||
|
t.Run(tt.name, func(t *testing.T) {
|
||
|
got, err := Call(tt.args.ctx, tt.args.desc, tt.args.endpoint)
|
||
|
switch {
|
||
|
case err == nil && tt.wantErr == nil:
|
||
|
// nothing
|
||
|
case err != nil && tt.wantErr == nil:
|
||
|
t.Fatalf("expected <nil> error but got %s", err.Error())
|
||
|
case err == nil && tt.wantErr != nil:
|
||
|
t.Fatalf("expected %s but got <nil>", tt.wantErr.Error())
|
||
|
case err.Error() == tt.wantErr.Error():
|
||
|
// nothing
|
||
|
default:
|
||
|
t.Fatalf("expected %s but got %s", err.Error(), tt.wantErr.Error())
|
||
|
}
|
||
|
if diff := cmp.Diff(tt.want, got); diff != "" {
|
||
|
t.Fatal(diff)
|
||
|
}
|
||
|
})
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func TestCallWithJSONResponse(t *testing.T) {
|
||
|
type response struct {
|
||
|
Name string
|
||
|
Age int64
|
||
|
}
|
||
|
expectedResponse := response{
|
||
|
Name: "sbs",
|
||
|
Age: 99,
|
||
|
}
|
||
|
type args struct {
|
||
|
ctx context.Context
|
||
|
desc *Descriptor
|
||
|
endpoint *Endpoint
|
||
|
}
|
||
|
tests := []struct {
|
||
|
name string
|
||
|
args args
|
||
|
wantErr error
|
||
|
errfn func(*testing.T, error)
|
||
|
}{{
|
||
|
name: "call fails",
|
||
|
args: args{
|
||
|
ctx: context.Background(),
|
||
|
desc: &Descriptor{
|
||
|
Logger: model.DiscardLogger,
|
||
|
},
|
||
|
endpoint: &Endpoint{
|
||
|
BaseURL: "\t\t\t\t", // causes failure
|
||
|
},
|
||
|
},
|
||
|
wantErr: errors.New(`parse "\t\t\t\t": net/url: invalid control character in URL`),
|
||
|
errfn: nil,
|
||
|
}, {
|
||
|
name: "with error during httpClient.Do",
|
||
|
args: args{
|
||
|
ctx: context.Background(),
|
||
|
desc: &Descriptor{
|
||
|
Logger: model.DiscardLogger,
|
||
|
},
|
||
|
endpoint: &Endpoint{
|
||
|
BaseURL: "https://www.example.com/a",
|
||
|
HTTPClient: &mocks.HTTPClient{
|
||
|
MockDo: func(req *http.Request) (*http.Response, error) {
|
||
|
return nil, io.EOF
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
wantErr: io.EOF,
|
||
|
errfn: func(t *testing.T, err error) {
|
||
|
var expect *errMaybeCensorship
|
||
|
if !errors.As(err, &expect) {
|
||
|
t.Fatal("invalid error type")
|
||
|
}
|
||
|
},
|
||
|
}, {
|
||
|
name: "with error when reading the response body",
|
||
|
args: args{
|
||
|
ctx: context.Background(),
|
||
|
desc: &Descriptor{
|
||
|
Logger: model.DiscardLogger,
|
||
|
},
|
||
|
endpoint: &Endpoint{
|
||
|
BaseURL: "https://www.example.com/a",
|
||
|
HTTPClient: &mocks.HTTPClient{
|
||
|
MockDo: func(req *http.Request) (*http.Response, error) {
|
||
|
resp := &http.Response{
|
||
|
Body: io.NopCloser(&mocks.Reader{
|
||
|
MockRead: func(b []byte) (int, error) {
|
||
|
return 0, netxlite.ECONNRESET
|
||
|
},
|
||
|
}),
|
||
|
StatusCode: 200,
|
||
|
}
|
||
|
return resp, nil
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
wantErr: errors.New(netxlite.FailureConnectionReset),
|
||
|
errfn: func(t *testing.T, err error) {
|
||
|
var expect *errMaybeCensorship
|
||
|
if !errors.As(err, &expect) {
|
||
|
t.Fatal("invalid error type")
|
||
|
}
|
||
|
},
|
||
|
}, {
|
||
|
name: "with HTTP failure",
|
||
|
args: args{
|
||
|
ctx: context.Background(),
|
||
|
desc: &Descriptor{
|
||
|
Logger: model.DiscardLogger,
|
||
|
},
|
||
|
endpoint: &Endpoint{
|
||
|
BaseURL: "https://www.example.com/a",
|
||
|
HTTPClient: &mocks.HTTPClient{
|
||
|
MockDo: func(req *http.Request) (*http.Response, error) {
|
||
|
resp := &http.Response{
|
||
|
Body: io.NopCloser(strings.NewReader(`{"Name": "sbs", "Age": 99}`)),
|
||
|
StatusCode: 400,
|
||
|
}
|
||
|
return resp, nil
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
wantErr: errors.New("httpapi: http request failed: 400"),
|
||
|
errfn: func(t *testing.T, err error) {
|
||
|
var expect *ErrHTTPRequestFailed
|
||
|
if !errors.As(err, &expect) {
|
||
|
t.Fatal("invalid error type")
|
||
|
}
|
||
|
},
|
||
|
}, {
|
||
|
name: "with good response and missing header",
|
||
|
args: args{
|
||
|
ctx: context.Background(),
|
||
|
desc: &Descriptor{
|
||
|
Logger: model.DiscardLogger,
|
||
|
},
|
||
|
endpoint: &Endpoint{
|
||
|
BaseURL: "https://www.example.com/a",
|
||
|
HTTPClient: &mocks.HTTPClient{
|
||
|
MockDo: func(req *http.Request) (*http.Response, error) {
|
||
|
resp := &http.Response{
|
||
|
Body: io.NopCloser(strings.NewReader(`{"Name": "sbs", "Age": 99}`)),
|
||
|
StatusCode: 200,
|
||
|
}
|
||
|
return resp, nil
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
wantErr: nil,
|
||
|
errfn: nil,
|
||
|
}, {
|
||
|
name: "with good response and good header",
|
||
|
args: args{
|
||
|
ctx: context.Background(),
|
||
|
desc: &Descriptor{
|
||
|
Logger: model.DiscardLogger,
|
||
|
},
|
||
|
endpoint: &Endpoint{
|
||
|
BaseURL: "https://www.example.com/a",
|
||
|
HTTPClient: &mocks.HTTPClient{
|
||
|
MockDo: func(req *http.Request) (*http.Response, error) {
|
||
|
resp := &http.Response{
|
||
|
Header: http.Header{
|
||
|
"Content-Type": {"application/json"},
|
||
|
},
|
||
|
Body: io.NopCloser(strings.NewReader(`{"Name": "sbs", "Age": 99}`)),
|
||
|
StatusCode: 200,
|
||
|
}
|
||
|
return resp, nil
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
wantErr: nil,
|
||
|
errfn: nil,
|
||
|
}, {
|
||
|
name: "response is not JSON",
|
||
|
args: args{
|
||
|
ctx: context.Background(),
|
||
|
desc: &Descriptor{
|
||
|
LogBody: false,
|
||
|
Logger: model.DiscardLogger,
|
||
|
Method: http.MethodGet,
|
||
|
},
|
||
|
endpoint: &Endpoint{
|
||
|
BaseURL: "https://www.example.com/",
|
||
|
HTTPClient: &mocks.HTTPClient{
|
||
|
MockDo: func(req *http.Request) (*http.Response, error) {
|
||
|
resp := &http.Response{
|
||
|
Header: http.Header{
|
||
|
"Content-Type": {"application/json"},
|
||
|
},
|
||
|
Body: io.NopCloser(strings.NewReader(`{`)), // invalid JSON
|
||
|
StatusCode: 200,
|
||
|
}
|
||
|
return resp, nil
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
wantErr: errors.New("unexpected end of JSON input"),
|
||
|
errfn: nil,
|
||
|
}}
|
||
|
for _, tt := range tests {
|
||
|
t.Run(tt.name, func(t *testing.T) {
|
||
|
var response response
|
||
|
err := CallWithJSONResponse(tt.args.ctx, tt.args.desc, tt.args.endpoint, &response)
|
||
|
switch {
|
||
|
case err == nil && tt.wantErr == nil:
|
||
|
if diff := cmp.Diff(expectedResponse, response); err != nil {
|
||
|
t.Fatal(diff)
|
||
|
}
|
||
|
case err != nil && tt.wantErr == nil:
|
||
|
t.Fatalf("expected <nil> error but got %s", err.Error())
|
||
|
case err == nil && tt.wantErr != nil:
|
||
|
t.Fatalf("expected %s but got <nil>", tt.wantErr.Error())
|
||
|
case err.Error() == tt.wantErr.Error():
|
||
|
// nothing
|
||
|
default:
|
||
|
t.Fatalf("expected %s but got %s", err.Error(), tt.wantErr.Error())
|
||
|
}
|
||
|
if tt.errfn != nil {
|
||
|
tt.errfn(t, err)
|
||
|
}
|
||
|
})
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func TestCallHonoursContext(t *testing.T) {
|
||
|
ctx, cancel := context.WithCancel(context.Background())
|
||
|
cancel() // should fail HTTP request immediately
|
||
|
desc := &Descriptor{
|
||
|
LogBody: false,
|
||
|
Logger: model.DiscardLogger,
|
||
|
Method: http.MethodGet,
|
||
|
URLPath: "/robots.txt",
|
||
|
}
|
||
|
endpoint := &Endpoint{
|
||
|
BaseURL: "https://www.example.com/",
|
||
|
HTTPClient: http.DefaultClient,
|
||
|
UserAgent: model.HTTPHeaderUserAgent,
|
||
|
}
|
||
|
body, err := Call(ctx, desc, endpoint)
|
||
|
if !errors.Is(err, context.Canceled) {
|
||
|
t.Fatal("unexpected err", err)
|
||
|
}
|
||
|
if len(body) > 0 {
|
||
|
t.Fatal("expected zero-length body")
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func TestCallWithJSONResponseHonoursContext(t *testing.T) {
|
||
|
ctx, cancel := context.WithCancel(context.Background())
|
||
|
cancel() // should fail HTTP request immediately
|
||
|
desc := &Descriptor{
|
||
|
LogBody: false,
|
||
|
Logger: model.DiscardLogger,
|
||
|
Method: http.MethodGet,
|
||
|
URLPath: "/robots.txt",
|
||
|
}
|
||
|
endpoint := &Endpoint{
|
||
|
BaseURL: "https://www.example.com/",
|
||
|
HTTPClient: http.DefaultClient,
|
||
|
UserAgent: model.HTTPHeaderUserAgent,
|
||
|
}
|
||
|
var resp url.URL
|
||
|
err := CallWithJSONResponse(ctx, desc, endpoint, &resp)
|
||
|
if !errors.Is(err, context.Canceled) {
|
||
|
t.Fatal("unexpected err", err)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func TestDescriptorLogging(t *testing.T) {
|
||
|
|
||
|
// This test was originally written for the httpx package and we have adapted it
|
||
|
// by keeping the ~same implementation with a custom callx function that converts
|
||
|
// the previous semantics of httpx to the new semantics of httpapi.
|
||
|
callx := func(baseURL string, logBody bool, logger model.Logger, request, response any) error {
|
||
|
desc := MustNewPOSTJSONWithJSONResponseDescriptor(logger, "/", request).WithBodyLogging(logBody)
|
||
|
runtimex.Assert(desc.LogBody == logBody, "desc.LogBody should be equal to logBody here")
|
||
|
endpoint := &Endpoint{
|
||
|
BaseURL: baseURL,
|
||
|
HTTPClient: http.DefaultClient,
|
||
|
}
|
||
|
return CallWithJSONResponse(context.Background(), desc, endpoint, response)
|
||
|
}
|
||
|
|
||
|
// we also needed to create a constructor for the logger
|
||
|
newlogger := func(logs chan string) model.Logger {
|
||
|
return &mocks.Logger{
|
||
|
MockDebugf: func(format string, v ...interface{}) {
|
||
|
logs <- fmt.Sprintf(format, v...)
|
||
|
},
|
||
|
MockWarnf: func(format string, v ...interface{}) {
|
||
|
logs <- fmt.Sprintf(format, v...)
|
||
|
},
|
||
|
}
|
||
|
}
|
||
|
|
||
|
t.Run("body logging enabled, 200 Ok, and without content-type", func(t *testing.T) {
|
||
|
server := httptest.NewServer(http.HandlerFunc(
|
||
|
func(w http.ResponseWriter, r *http.Request) {
|
||
|
w.Write([]byte("[]"))
|
||
|
},
|
||
|
))
|
||
|
logs := make(chan string, 1024)
|
||
|
defer server.Close()
|
||
|
var (
|
||
|
input []string
|
||
|
output []string
|
||
|
)
|
||
|
logger := newlogger(logs)
|
||
|
err := callx(server.URL, true, logger, input, &output)
|
||
|
var found int
|
||
|
close(logs)
|
||
|
for entry := range logs {
|
||
|
if strings.HasPrefix(entry, "httpapi: request body: ") {
|
||
|
// we expect this because body logging is enabled
|
||
|
found |= 1 << 0
|
||
|
continue
|
||
|
}
|
||
|
if strings.HasPrefix(entry, "httpapi: response body: ") {
|
||
|
// we expect this because body logging is enabled
|
||
|
found |= 1 << 1
|
||
|
continue
|
||
|
}
|
||
|
if strings.HasPrefix(entry, "httpapi: unexpected content-type: ") {
|
||
|
// we would expect this because the server does not send us any content-type
|
||
|
found |= 1 << 2
|
||
|
continue
|
||
|
}
|
||
|
if strings.HasPrefix(entry, "httpapi: request body length: ") {
|
||
|
// we should see this because we sent a body
|
||
|
found |= 1 << 3
|
||
|
continue
|
||
|
}
|
||
|
if strings.HasPrefix(entry, "httpapi: response body length: ") {
|
||
|
// we should see this because we receive a body
|
||
|
found |= 1 << 4
|
||
|
continue
|
||
|
}
|
||
|
}
|
||
|
if found != (1<<0 | 1<<1 | 1<<2 | 1<<3 | 1<<4) {
|
||
|
t.Fatal("did not find the expected logs")
|
||
|
}
|
||
|
if err != nil {
|
||
|
t.Fatal(err)
|
||
|
}
|
||
|
})
|
||
|
|
||
|
t.Run("body logging enabled, 200 Ok, and with content-type", func(t *testing.T) {
|
||
|
server := httptest.NewServer(http.HandlerFunc(
|
||
|
func(w http.ResponseWriter, r *http.Request) {
|
||
|
w.Header().Add("content-type", "application/json")
|
||
|
w.Write([]byte("[]"))
|
||
|
},
|
||
|
))
|
||
|
logs := make(chan string, 1024)
|
||
|
defer server.Close()
|
||
|
var (
|
||
|
input []string
|
||
|
output []string
|
||
|
)
|
||
|
logger := newlogger(logs)
|
||
|
err := callx(server.URL, true, logger, input, &output)
|
||
|
var found int
|
||
|
close(logs)
|
||
|
for entry := range logs {
|
||
|
if strings.HasPrefix(entry, "httpapi: request body: ") {
|
||
|
// we expect this because body logging is enabled
|
||
|
found |= 1 << 0
|
||
|
continue
|
||
|
}
|
||
|
if strings.HasPrefix(entry, "httpapi: response body: ") {
|
||
|
// we expect this because body logging is enabled
|
||
|
found |= 1 << 1
|
||
|
continue
|
||
|
}
|
||
|
if strings.HasPrefix(entry, "httpapi: unexpected content-type: ") {
|
||
|
// we do not expect this because the server sends us a content-type
|
||
|
found |= 1 << 2
|
||
|
continue
|
||
|
}
|
||
|
if strings.HasPrefix(entry, "httpapi: request body length: ") {
|
||
|
// we should see this because we sent a body
|
||
|
found |= 1 << 3
|
||
|
continue
|
||
|
}
|
||
|
if strings.HasPrefix(entry, "httpapi: response body length: ") {
|
||
|
// we should see this because we receive a body
|
||
|
found |= 1 << 4
|
||
|
continue
|
||
|
}
|
||
|
}
|
||
|
if found != (1<<0 | 1<<1 | 1<<3 | 1<<4) {
|
||
|
t.Fatal("did not find the expected logs")
|
||
|
}
|
||
|
if err != nil {
|
||
|
t.Fatal(err)
|
||
|
}
|
||
|
})
|
||
|
|
||
|
t.Run("body logging enabled and 401 Unauthorized", func(t *testing.T) {
|
||
|
server := httptest.NewServer(http.HandlerFunc(
|
||
|
func(w http.ResponseWriter, r *http.Request) {
|
||
|
w.WriteHeader(401)
|
||
|
w.Write([]byte("[]"))
|
||
|
},
|
||
|
))
|
||
|
logs := make(chan string, 1024)
|
||
|
defer server.Close()
|
||
|
var (
|
||
|
input []string
|
||
|
output []string
|
||
|
)
|
||
|
logger := newlogger(logs)
|
||
|
err := callx(server.URL, true, logger, input, &output)
|
||
|
var found int
|
||
|
close(logs)
|
||
|
for entry := range logs {
|
||
|
if strings.HasPrefix(entry, "httpapi: request body: ") {
|
||
|
// should occur because body logging is enabled
|
||
|
found |= 1 << 0
|
||
|
continue
|
||
|
}
|
||
|
if strings.HasPrefix(entry, "httpapi: response body: ") {
|
||
|
// should occur because body logging is enabled
|
||
|
found |= 1 << 1
|
||
|
continue
|
||
|
}
|
||
|
if strings.HasPrefix(entry, "httpapi: unexpected content-type: ") {
|
||
|
// note: this one should not occur because the code is 401 so we're not
|
||
|
// actually going to parse the JSON document
|
||
|
found |= 1 << 2
|
||
|
continue
|
||
|
}
|
||
|
if strings.HasPrefix(entry, "httpapi: request body length: ") {
|
||
|
// we should see this because we send a body
|
||
|
found |= 1 << 3
|
||
|
continue
|
||
|
}
|
||
|
if strings.HasPrefix(entry, "httpapi: response body length: ") {
|
||
|
// we should see this because we receive a body
|
||
|
found |= 1 << 4
|
||
|
continue
|
||
|
}
|
||
|
}
|
||
|
if found != (1<<0 | 1<<1 | 1<<3 | 1<<4) {
|
||
|
t.Fatal("did not find the expected logs")
|
||
|
}
|
||
|
var failure *ErrHTTPRequestFailed
|
||
|
if !errors.As(err, &failure) || failure.StatusCode != 401 {
|
||
|
t.Fatal("unexpected err", err)
|
||
|
}
|
||
|
})
|
||
|
|
||
|
t.Run("body logging NOT enabled and 200 Ok", func(t *testing.T) {
|
||
|
server := httptest.NewServer(http.HandlerFunc(
|
||
|
func(w http.ResponseWriter, r *http.Request) {
|
||
|
w.Write([]byte("[]"))
|
||
|
},
|
||
|
))
|
||
|
logs := make(chan string, 1024)
|
||
|
defer server.Close()
|
||
|
var (
|
||
|
input []string
|
||
|
output []string
|
||
|
)
|
||
|
logger := newlogger(logs)
|
||
|
err := callx(server.URL, false, logger, input, &output) // no logging
|
||
|
var found int
|
||
|
close(logs)
|
||
|
for entry := range logs {
|
||
|
if strings.HasPrefix(entry, "httpapi: request body: ") {
|
||
|
// should not see it: body logging is disabled
|
||
|
found |= 1 << 0
|
||
|
continue
|
||
|
}
|
||
|
if strings.HasPrefix(entry, "httpapi: response body: ") {
|
||
|
// should not see it: body logging is disabled
|
||
|
found |= 1 << 1
|
||
|
continue
|
||
|
}
|
||
|
if strings.HasPrefix(entry, "httpapi: unexpected content-type: ") {
|
||
|
// this one should be logged ANYWAY because it's orthogonal to the
|
||
|
// body logging so we should see it also in this case.
|
||
|
found |= 1 << 2
|
||
|
continue
|
||
|
}
|
||
|
if strings.HasPrefix(entry, "httpapi: request body length: ") {
|
||
|
// should see this because we send a body
|
||
|
found |= 1 << 3
|
||
|
continue
|
||
|
}
|
||
|
if strings.HasPrefix(entry, "httpapi: response body length: ") {
|
||
|
// should see this because we're receiving a body
|
||
|
found |= 1 << 4
|
||
|
continue
|
||
|
}
|
||
|
}
|
||
|
if found != (1<<2 | 1<<3 | 1<<4) {
|
||
|
t.Fatal("did not find the expected logs")
|
||
|
}
|
||
|
if err != nil {
|
||
|
t.Fatal(err)
|
||
|
}
|
||
|
})
|
||
|
|
||
|
t.Run("body logging NOT enabled and 401 Unauthorized", func(t *testing.T) {
|
||
|
server := httptest.NewServer(http.HandlerFunc(
|
||
|
func(w http.ResponseWriter, r *http.Request) {
|
||
|
w.WriteHeader(401)
|
||
|
w.Write([]byte("[]"))
|
||
|
},
|
||
|
))
|
||
|
logs := make(chan string, 1024)
|
||
|
defer server.Close()
|
||
|
var (
|
||
|
input []string
|
||
|
output []string
|
||
|
)
|
||
|
logger := newlogger(logs)
|
||
|
err := callx(server.URL, false, logger, input, &output) // no logging
|
||
|
var found int
|
||
|
close(logs)
|
||
|
for entry := range logs {
|
||
|
if strings.HasPrefix(entry, "httpapi: request body: ") {
|
||
|
// should not see it: body logging is disabled
|
||
|
found |= 1 << 0
|
||
|
continue
|
||
|
}
|
||
|
if strings.HasPrefix(entry, "httpapi: response body: ") {
|
||
|
// should not see it: body logging is disabled
|
||
|
found |= 1 << 1
|
||
|
continue
|
||
|
}
|
||
|
if strings.HasPrefix(entry, "httpapi: unexpected content-type: ") {
|
||
|
// should not see it because we don't parse the body on 401 errors
|
||
|
found |= 1 << 2
|
||
|
continue
|
||
|
}
|
||
|
if strings.HasPrefix(entry, "httpapi: request body length: ") {
|
||
|
// we send a body so we should see it
|
||
|
found |= 1 << 3
|
||
|
continue
|
||
|
}
|
||
|
if strings.HasPrefix(entry, "httpapi: response body length: ") {
|
||
|
// we receive a body so we should see it
|
||
|
found |= 1 << 4
|
||
|
continue
|
||
|
}
|
||
|
}
|
||
|
if found != (1<<3 | 1<<4) {
|
||
|
t.Fatal("did not find the expected logs")
|
||
|
}
|
||
|
var failure *ErrHTTPRequestFailed
|
||
|
if !errors.As(err, &failure) || failure.StatusCode != 401 {
|
||
|
t.Fatal("unexpected err", err)
|
||
|
}
|
||
|
})
|
||
|
}
|
||
|
|
||
|
func Test_errMaybeCensorship_Unwrap(t *testing.T) {
|
||
|
t.Run("for errors.Is", func(t *testing.T) {
|
||
|
var err error = &errMaybeCensorship{io.EOF}
|
||
|
if !errors.Is(err, io.EOF) {
|
||
|
t.Fatal("cannot unwrap")
|
||
|
}
|
||
|
})
|
||
|
|
||
|
t.Run("for errors.As", func(t *testing.T) {
|
||
|
var err error = &errMaybeCensorship{netxlite.ECONNRESET}
|
||
|
var syserr syscall.Errno
|
||
|
if !errors.As(err, &syserr) || syserr != netxlite.ECONNRESET {
|
||
|
t.Fatal("cannot unwrap")
|
||
|
}
|
||
|
})
|
||
|
}
|