Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Mocking HTTPS responses in Go

Tags:

ssl

testing

go

I'm trying to write tests for a package that makes requests to a web service. I'm running into issues probably due to my lack of understanding of TLS.

Currently my test looks something like this:

func TestSimple() {
    server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(200)
        fmt.Fprintf(w, `{ "fake" : "json data here" }`)
    }))
    transport := &http.Transport{
        Proxy: func(req *http.Request) (*url.URL, error) {
            return url.Parse(server.URL)
        },
    }
    // Client is the type in my package that makes requests
    client := Client{
        c: http.Client{Transport: transport},
    }

    client.DoRequest() // ...
}

My package has a package variable (I'd like for it to be a constant..) for the base address of the web service to query. It is an https URL. The test server I created above is plain HTTP, no TLS.

By default, my test fails with the error "tls: first record does not look like a TLS handshake."

To get this to work, my tests change the package variable to a plain http URL instead of https before making the query.

Is there any way around this? Can I make the package variable a constant (https), and either set up a http.Transport that "downgrades" to unencrypted HTTP, or use httptest.NewTLSServer() instead?

(When I try to use NewTLSServer() I get "http: TLS handshake error from 127.0.0.1:45678: tls: oversized record received with length 20037")

like image 491
zmb Avatar asked Jan 10 '15 20:01

zmb


People also ask

How do you read HTTP response body in Golang?

To read the body of the response, we need to access its Body property first. We can access the Body property of a response using the ioutil. ReadAll() method. This method returns a body and an error.

What is Httpmock?

Easy mocking of http responses from external resources.

What is Golang mockery?

mockery provides the ability to easily generate mocks for golang interfaces using the stretchr/testify/mock package. It removes the boilerplate coding required to use mocks.


Video Answer


3 Answers

Most of the behavior in net/http can be mocked, extended, or altered. Although http.Client is a concrete type that implements HTTP client semantics, all of its fields are exported and may be customized.

The Client.Transport field, in particular, may be replaced to make the Client do anything from using custom protocols (such as ftp:// or file://) to connecting directly to local handlers (without generating HTTP protocol bytes or sending anything over the network).

The client functions, such as http.Get, all utilize the exported http.DefaultClient package variable (which you may modify), so code that utilizes these convenience functions does not, for example, have to be changed to call methods on a custom Client variable. Note that while it would be unreasonable to modify global behavior in a publicly-available library, it's very useful to do so in applications and tests (including library tests).

http://play.golang.org/p/afljO086iB contains a custom http.RoundTripper that rewrites the request URL so that it'll be routed to a locally hosted httptest.Server, and another example that directly passes the request to an http.Handler, along with a custom http.ResponseWriter implementation, in order to create an http.Response. The second approach isn't as diligent as the first (it doesn't fill out as many fields in the Response value) but is more efficient, and should be compatible enough to work with most handlers and client callers.

The above-linked code is included below as well:

package main

import (
    "fmt"
    "io"
    "log"
    "net/http"
    "net/http/httptest"
    "net/url"
    "os"
    "path"
    "strings"
)

func Handler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "hello %s\n", path.Base(r.URL.Path))
}

func main() {
    s := httptest.NewServer(http.HandlerFunc(Handler))
    u, err := url.Parse(s.URL)
    if err != nil {
        log.Fatalln("failed to parse httptest.Server URL:", err)
    }
    http.DefaultClient.Transport = RewriteTransport{URL: u}
    resp, err := http.Get("https://google.com/path-one")
    if err != nil {
        log.Fatalln("failed to send first request:", err)
    }
    fmt.Println("[First Response]")
    resp.Write(os.Stdout)

    fmt.Print("\n", strings.Repeat("-", 80), "\n\n")

    http.DefaultClient.Transport = HandlerTransport{http.HandlerFunc(Handler)}
    resp, err = http.Get("https://google.com/path-two")
    if err != nil {
        log.Fatalln("failed to send second request:", err)
    }
    fmt.Println("[Second Response]")
    resp.Write(os.Stdout)
}

// RewriteTransport is an http.RoundTripper that rewrites requests
// using the provided URL's Scheme and Host, and its Path as a prefix.
// The Opaque field is untouched.
// If Transport is nil, http.DefaultTransport is used
type RewriteTransport struct {
    Transport http.RoundTripper
    URL       *url.URL
}

func (t RewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) {
    // note that url.URL.ResolveReference doesn't work here
    // since t.u is an absolute url
    req.URL.Scheme = t.URL.Scheme
    req.URL.Host = t.URL.Host
    req.URL.Path = path.Join(t.URL.Path, req.URL.Path)
    rt := t.Transport
    if rt == nil {
        rt = http.DefaultTransport
    }
    return rt.RoundTrip(req)
}

type HandlerTransport struct{ h http.Handler }

func (t HandlerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
    r, w := io.Pipe()
    resp := &http.Response{
        Proto:      "HTTP/1.1",
        ProtoMajor: 1,
        ProtoMinor: 1,
        Header:     make(http.Header),
        Body:       r,
        Request:    req,
    }
    ready := make(chan struct{})
    prw := &pipeResponseWriter{r, w, resp, ready}
    go func() {
        defer w.Close()
        t.h.ServeHTTP(prw, req)
    }()
    <-ready
    return resp, nil
}

type pipeResponseWriter struct {
    r     *io.PipeReader
    w     *io.PipeWriter
    resp  *http.Response
    ready chan<- struct{}
}

func (w *pipeResponseWriter) Header() http.Header {
    return w.resp.Header
}

func (w *pipeResponseWriter) Write(p []byte) (int, error) {
    if w.ready != nil {
        w.WriteHeader(http.StatusOK)
    }
    return w.w.Write(p)
}

func (w *pipeResponseWriter) WriteHeader(status int) {
    if w.ready == nil {
        // already called
        return
    }
    w.resp.StatusCode = status
    w.resp.Status = fmt.Sprintf("%d %s", status, http.StatusText(status))
    close(w.ready)
    w.ready = nil
}
like image 122
krait Avatar answered Oct 20 '22 19:10

krait


The reason you're getting the error http: TLS handshake error from 127.0.0.1:45678: tls: oversized record received with length 20037 is because https requires a domain name (not an IP Address). Domain names are SSL certificates are assigned to.

Start the httptest server in TLS mode with your own certs

cert, err := tls.LoadX509KeyPair("cert.pem", "key.pem")
if err != nil {
    log.Panic("bad server certs: ", err)
}
certs := []tls.Certificate{cert}

server = httptest.NewUnstartedServer(router)
server.TLS = &tls.Config{Certificates: certs}
server.StartTLS()
serverPort = ":" + strings.Split(server.URL, ":")[2] // it's always https://127.0.0.1:<port>
server.URL = "https://sub.domain.com" + serverPort

To provide a valid SSL certificate for a connection are the options of:

  1. Not supplying a cert and key
  2. Supplying a self-signed cert and key
  3. Supplying a real valid cert and key

No Cert

If you don't supply your own cert, then an example.com cert is loaded as default.

Self-Signed Cert

To create a testing cert can use the included self-signed cert generator at $GOROOT/src/crypto/tls/generate_cert.go --host "*.domain.name"

You'll get x509: certificate signed by unknown authority warnings because it's self-signed so you'll need to have your client skip those warnings, by adding the following to your http.Transport field:

 TLSClientConfig: &tls.Config{InsecureSkipVerify: true}

Valid Real Cert

Finally, if you're going to use a real cert, then save the valid cert and key where they can be loaded.


The key here is to use server.URL = https://sub.domain.com to supply your own domain.

like image 35
null Avatar answered Oct 20 '22 18:10

null


From Go 1.9+ you can use func (s *Server) Client() *http.Client in the httptest package:

Client returns an HTTP client configured for making requests to the server. It is configured to trust the server's TLS test certificate and will close its idle connections on Server.Close.

Example from the package:

package main

import (
    "fmt"
    "io"
    "log"
    "net/http"
    "net/http/httptest"
)

func main() {
    ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "Hello, client")
    }))
    defer ts.Close()

    client := ts.Client()
    res, err := client.Get(ts.URL)
    if err != nil {
        log.Fatal(err)
    }

    greeting, err := io.ReadAll(res.Body)
    res.Body.Close()
    if err != nil {
        log.Fatal(err)
    }

    fmt.Printf("%s", greeting)
}

like image 42
recio Avatar answered Oct 20 '22 19:10

recio