Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions internal/mlablocate/mlablocate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Package mlablocate contains a locate.measurementlab.net client.
package mlablocate

import (
"context"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"net/url"

"github.com/ooni/probe-engine/model"
)

// NewRequestFunc is the function to create a new request.
type NewRequestFunc func(ctx context.Context, URL *url.URL) (*http.Request, error)

// Client is a locate.measurementlab.net client.
type Client struct {
HTTPClient *http.Client
Hostname string
Logger model.Logger
NewRequest NewRequestFunc
Scheme string
UserAgent string
}

// NewClient creates a new locate.measurementlab.net client.
func NewClient(httpClient *http.Client, logger model.Logger, userAgent string) *Client {
return &Client{
HTTPClient: httpClient,
Hostname: "locate.measurementlab.net",
Logger: logger,
NewRequest: NewRequestDefault(),
Scheme: "https",
UserAgent: userAgent,
}
}

// NewRequestDefault return the default implementation of the c.NewRequest
// for creating a new HTTP request for locate.measurementlab.net.
func NewRequestDefault() NewRequestFunc {
return func(ctx context.Context, URL *url.URL) (*http.Request, error) {
return http.NewRequestWithContext(ctx, "GET", URL.String(), nil)
}
}

// NewRequestWithProxy returns a new request factory that tells to the
// locate.measurementlab.net service that we're using a proxy such that
// the returned host is good for us, not for the proxy.
func NewRequestWithProxy(probeIP string) NewRequestFunc {
return func(ctx context.Context, URL *url.URL) (*http.Request, error) {
values := URL.Query()
values.Set("ip", probeIP)
URL.RawQuery = values.Encode()
return http.NewRequestWithContext(ctx, "GET", URL.String(), nil)
}
}

type locateResult struct {
FQDN string `json:"fqdn"`
}

// Query performs a locate.measurementlab.net query.
func (c *Client) Query(ctx context.Context, tool string) (string, error) {
URL := &url.URL{
Scheme: c.Scheme,
Host: c.Hostname,
Path: tool,
}
req, err := c.NewRequest(ctx, URL)
if err != nil {
return "", err
}
req.Header.Add("User-Agent", c.UserAgent)
c.Logger.Debugf("mlablocate: GET %s", URL.String())
resp, err := c.HTTPClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("mlablocate: non-200 status code: %d", resp.StatusCode)
}
data, err := ioutil.ReadAll(resp.Body)
if err != nil {
return "", err
}
c.Logger.Debugf("mlablocate: %s", string(data))
var result locateResult
if err := json.Unmarshal(data, &result); err != nil {
return "", err
}
if result.FQDN == "" {
return "", errors.New("mlablocate: returned empty FQDN")
}
return result.FQDN, nil
}
224 changes: 224 additions & 0 deletions internal/mlablocate/mlablocate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
package mlablocate_test

import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"

"github.com/apex/log"
"github.com/ooni/probe-engine/internal/mlablocate"
)

func TestIntegrationWithoutProxy(t *testing.T) {
client := mlablocate.NewClient(
http.DefaultClient,
log.Log,
"miniooni/0.1.0-dev",
)
fqdn, err := client.Query(context.Background(), "ndt7")
if err != nil {
t.Fatal(err)
}
if fqdn == "" {
t.Fatal("unexpected empty fqdn")
}
t.Log(fqdn)
}

func TestIntegrationWithProxy(t *testing.T) {
client := mlablocate.NewClient(
http.DefaultClient,
log.Log,
"miniooni/0.1.0-dev",
)
client.NewRequest = mlablocate.NewRequestWithProxy("8.8.8.8")
fqdn, err := client.Query(context.Background(), "ndt7")
if err != nil {
t.Fatal(err)
}
if fqdn == "" {
t.Fatal("unexpected empty fqdn")
}
t.Log(fqdn)
}

func TestIntegration404Response(t *testing.T) {
client := mlablocate.NewClient(
http.DefaultClient,
log.Log,
"miniooni/0.1.0-dev",
)
fqdn, err := client.Query(context.Background(), "nonexistent")
if err == nil || !strings.Contains(err.Error(), "mlablocate: non-200 status code") {
t.Fatal("not the error we expected")
}
if fqdn != "" {
t.Fatal("expected empty fqdn")
}
}

func TestUnitNewRequestFailure(t *testing.T) {
client := mlablocate.NewClient(
http.DefaultClient,
log.Log,
"miniooni/0.1.0-dev",
)
client.Hostname = "\t"
fqdn, err := client.Query(context.Background(), "nonexistent")
if err == nil || !strings.Contains(err.Error(), "invalid URL escape") {
t.Fatal("not the error we expected")
}
if fqdn != "" {
t.Fatal("expected empty fqdn")
}
}

func TestUnitHTTPClientDoFailure(t *testing.T) {
client := mlablocate.NewClient(
http.DefaultClient,
log.Log,
"miniooni/0.1.0-dev",
)
expected := errors.New("mocked error")
client.HTTPClient = &http.Client{
Transport: &roundTripFails{Error: expected},
}
fqdn, err := client.Query(context.Background(), "nonexistent")
if !errors.Is(err, expected) {
t.Fatal("not the error we expected")
}
if fqdn != "" {
t.Fatal("expected empty fqdn")
}
}

type roundTripFails struct {
Error error
}

func (txp *roundTripFails) RoundTrip(*http.Request) (*http.Response, error) {
return nil, txp.Error
}

func TestUnitCannotReadBody(t *testing.T) {
client := mlablocate.NewClient(
http.DefaultClient,
log.Log,
"miniooni/0.1.0-dev",
)
expected := errors.New("mocked error")
client.HTTPClient = &http.Client{
Transport: &readingBodyFails{Error: expected},
}
fqdn, err := client.Query(context.Background(), "nonexistent")
if !errors.Is(err, expected) {
t.Fatal("not the error we expected")
}
if fqdn != "" {
t.Fatal("expected empty fqdn")
}
}

type readingBodyFails struct {
Error error
}

func (txp *readingBodyFails) RoundTrip(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 200,
Body: &readingBodyFailsBody{Error: txp.Error},
}, nil
}

type readingBodyFailsBody struct {
Error error
}

func (b *readingBodyFailsBody) Read(p []byte) (int, error) {
return 0, b.Error
}

func (b *readingBodyFailsBody) Close() error {
return nil
}

func TestUnitInvalidJSON(t *testing.T) {
client := mlablocate.NewClient(
http.DefaultClient,
log.Log,
"miniooni/0.1.0-dev",
)
client.HTTPClient = &http.Client{
Transport: &invalidJSON{},
}
fqdn, err := client.Query(context.Background(), "nonexistent")
if err == nil || !strings.Contains(err.Error(), "unexpected end of JSON input") {
t.Fatal("not the error we expected")
}
if fqdn != "" {
t.Fatal("expected empty fqdn")
}
}

type invalidJSON struct{}

func (txp *invalidJSON) RoundTrip(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 200,
Body: &invalidJSONBody{},
}, nil
}

type invalidJSONBody struct{}

func (b *invalidJSONBody) Read(p []byte) (int, error) {
if len(p) < 1 {
return 0, errors.New("slice too short")
}
p[0] = '{'
return 1, io.EOF
}

func (b *invalidJSONBody) Close() error {
return nil
}

func TestUnitEmptyFQDN(t *testing.T) {
client := mlablocate.NewClient(
http.DefaultClient,
log.Log,
"miniooni/0.1.0-dev",
)
client.HTTPClient = &http.Client{
Transport: &emptyFQDN{},
}
fqdn, err := client.Query(context.Background(), "nonexistent")
if err == nil || !strings.HasSuffix(err.Error(), "returned empty FQDN") {
t.Fatal("not the error we expected")
}
if fqdn != "" {
t.Fatal("expected empty fqdn")
}
}

type emptyFQDN struct{}

func (txp *emptyFQDN) RoundTrip(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 200,
Body: &emptyFQDNBody{},
}, nil
}

type emptyFQDNBody struct{}

func (b *emptyFQDNBody) Read(p []byte) (int, error) {
return copy(p, []byte(`{"fqdn":""}`)), io.EOF
}

func (b *emptyFQDNBody) Close() error {
return nil
}