HTTPS and mTLS mocks
This guide tests a client of an API that serves HTTPS only, first with a certificate the client trusts, then with mutual TLS, where the client must present a certificate of its own. Then the vlatte command serves the same mock with the same certificates. It uses TLS and mock files.
The client
// Client calls the inventory API, which serves HTTPS only.
type Client struct {
BaseURL string
HTTP *http.Client
}
func (c *Client) Stock(ctx context.Context, sku string) (int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/stock/"+sku, nil)
if err != nil {
return 0, err
}
res, err := c.HTTP.Do(req)
if err != nil {
return 0, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return 0, fmt.Errorf("inventory: stock of %s: %s", sku, res.Status)
}
var body struct {
Available int `json:"available"`
}
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
return 0, err
}
return body.Available, nil
}Certificates
mkcert makes a CA and the certificates it signs. Put them in testdata, next to the tests:
mkcert -cert-file testdata/localhost.pem -key-file testdata/localhost-key.pem localhost 127.0.0.1
mkcert -client -cert-file testdata/client.pem -key-file testdata/client-key.pem client
cp "$(mkcert -CAROOT)/rootCA.pem" testdata/ca.pemThe server certificate names 127.0.0.1, as m.URL() does. Keys of test CAs belong to tests only: never give one to a real service.
Without certificates of your own, m.MustStartTLS() serves a test certificate the client must skip verifying. That tests less: a client that verifies certificates, as it should, needs one it can trust.
Helpers
newInventory starts an HTTPS mock of the API with the TLS options it's given. clientTLS makes an HTTP client that trusts the test CA, with the client certificate or without it.
// newInventory starts an HTTPS mock of the inventory API with the setup given.
func newInventory(t *testing.T, setup *mockhttp.ConfigBuilder) *mockhttp.Server {
t.Helper()
m := mockhttp.MustNew(setup.Silent())
m.MustStartTLS()
t.Cleanup(m.Close)
m.MustMock(mockhttp.Getf("/stock/sku-1").Reply(mockhttp.OK().JSON(map[string]any{"available": 7})))
return m
}
// clientTLS trusts the test CA, and presents the client certificate when asked to.
func clientTLS(t *testing.T, withCert bool) *http.Client {
t.Helper()
ca, err := os.ReadFile("testdata/ca.pem")
require.NoError(t, err)
pool := x509.NewCertPool()
require.True(t, pool.AppendCertsFromPEM(ca))
conf := &tls.Config{RootCAs: pool}
if withCert {
cert, err := tls.LoadX509KeyPair("testdata/client.pem", "testdata/client-key.pem")
require.NoError(t, err)
conf.Certificates = []tls.Certificate{cert}
}
return &http.Client{Transport: &http.Transport{TLSClientConfig: conf}}
}Over HTTPS
TLSCertKeyPair serves the server certificate, which the client verifies against the CA:
func TestStock_OverHTTPS(t *testing.T) {
m := newInventory(t, mockhttp.Setup().TLSCertKeyPair("testdata/localhost.pem", "testdata/localhost-key.pem"))
client := &Client{BaseURL: m.URL(), HTTP: clientTLS(t, false)}
n, err := client.Stock(t.Context(), "sku-1")
require.NoError(t, err)
require.Equal(t, 7, n)
}With mutual TLS
TLSMutual serves the same certificate, and asks the client for one the CA signed. The client with a certificate gets the stock; the one without is refused before any request is sent, so no mock sees it:
func TestStock_WithMutualTLS(t *testing.T) {
m := newInventory(t, mockhttp.Setup().
TLSMutual("testdata/localhost.pem", "testdata/localhost-key.pem", "testdata/ca.pem"))
t.Run("a client with a certificate gets the stock", func(t *testing.T) {
client := &Client{BaseURL: m.URL(), HTTP: clientTLS(t, true)}
n, err := client.Stock(t.Context(), "sku-1")
require.NoError(t, err)
require.Equal(t, 7, n)
})
t.Run("a client without one is refused", func(t *testing.T) {
client := &Client{BaseURL: m.URL(), HTTP: clientTLS(t, false)}
_, err := client.Stock(t.Context(), "sku-1")
require.ErrorContains(t, err, "certificate required")
})
}The same mock from the CLI
The vlatte command serves a mock file with the same certificates:
# testdata/vlatte/_mocks/stock.mock.yaml
request:
method: GET
path: /stock/sku-1
response:
status: 200
header:
content-type: application/json
body: '{"available": 7}'vlatte --https --addr=127.0.0.1:8443 \
--tls-cert=testdata/localhost.pem --tls-key=testdata/localhost-key.pem --tls-ca=testdata/ca.pem
curl --cacert testdata/ca.pem --cert testdata/client.pem --key testdata/client-key.pem \
https://localhost:8443/stock/sku-1
# {"available": 7}In Docker, mount testdata and set VLATTE_HTTPS=true, VLATTE_TLS_CERT, VLATTE_TLS_KEY and VLATTE_TLS_CA: the image serves HTTPS on port 8443.