git » gofer » commit b3349f3

test: Add end to end tests

author Alberto Bertogli
2020-05-05 18:33:43 UTC
committer Alberto Bertogli
2020-05-06 02:25:38 UTC
parent a444349ba7116e4cac1157145d4886275eb7007d

test: Add end to end tests

This patch adds some end to end tests.

The main focus is on HTTP handling, but there are also some simple tests
for the raw proxy.

.gitignore +3 -1
Makefile +23 -0
go.sum +1 -0
test/01-be.conf +11 -0
test/01-fe.conf +39 -0
test/test.sh +148 -0
test/testdata/cgi.sh +16 -0
test/testdata/dir/hola +1 -0
test/testdata/dir/ñaca +1 -0
test/testdata/file +1 -0
test/util/exp.go +136 -0
test/util/generate_cert.go +123 -0

diff --git a/.gitignore b/.gitignore
index 664073b..0d967e7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,9 +7,11 @@
 # excluded.
 !.gitignore
 
-# The binary.
+# The binaries.
 gofer
+test/util/exp
 
 # Configuration and certificates, to prevent accidents.
 *.conf
 *.pem
+!test/*.conf
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..b102122
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,23 @@
+
+ifndef VERSION
+    VERSION = `git describe --always --long --dirty`
+endif
+
+# https://wiki.debian.org/ReproducibleBuilds/TimestampsProposal
+ifndef SOURCE_DATE_EPOCH
+    SOURCE_DATE_EPOCH = `git log -1 --format=%ct`
+endif
+
+default: gofer
+
+gofer:
+	go build -ldflags="\
+		-X main.version=${VERSION} \
+		-X main.sourceDateTs=${SOURCE_DATE_EPOCH} \
+		" ${GOFLAGS}
+
+test:
+	go test ./...
+	setsid -w ./test/test.sh
+
+.PHONY: gofer test
diff --git a/go.sum b/go.sum
index 96efb0d..5b34b45 100644
--- a/go.sum
+++ b/go.sum
@@ -9,4 +9,5 @@ golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0 h1:Jcxah/M+oLZ/R4/z5RzfPzGbP
 golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
diff --git a/test/01-be.conf b/test/01-be.conf
new file mode 100644
index 0000000..c60d021
--- /dev/null
+++ b/test/01-be.conf
@@ -0,0 +1,11 @@
+
+control_addr = "127.0.0.1:8459"
+
+[[http]]
+addr = ":8450"
+
+[http.routes]
+"/dir/" = "dir:testdata/dir"
+"/file" = "static:testdata/file"
+"/file/second" = "static:testdata/dir/ñaca"
+"/cgi/" = "cgi:testdata/cgi.sh?param 1;param 2"
diff --git a/test/01-fe.conf b/test/01-fe.conf
new file mode 100644
index 0000000..bfc8d07
--- /dev/null
+++ b/test/01-fe.conf
@@ -0,0 +1,39 @@
+
+control_addr = "127.0.0.1:8440"
+
+[[http]]
+addr = ":8441"
+base_routes = "default"
+
+[[https]]
+addr = ":8442"
+certs = ".certs"
+base_routes = "default"
+
+[https.routes]
+"/dar/" = "http://localhost:8450/dir/"
+
+[routes.default]
+"/dir/" = "http://localhost:8450/dir/"
+"/file" = "http://localhost:8450/file"
+"/cgi/" = "http://localhost:8450/cgi/"
+"/gogo/" = "redirect:https://google.com"
+
+# Target unreachable: connection refused.
+"/bad/unreacheable" = "http://localhost:1/"
+
+# Bad target.
+"/bad/empty" = "http:"
+
+
+# Raw proxy to the same backend.
+[[raw]]
+addr = ":8445"
+to = "localhost:8450"
+to_tls = false
+
+[[raw]]
+addr = ":8446"
+certs = ".certs"
+to = "localhost:8450"
+to_tls = false
diff --git a/test/test.sh b/test/test.sh
new file mode 100755
index 0000000..61aced8
--- /dev/null
+++ b/test/test.sh
@@ -0,0 +1,148 @@
+#!/bin/bash
+
+set -e
+
+if [ "$V" == "1" ]; then
+	set -v
+fi
+
+UTILDIR="$( realpath `dirname "${0}"` )/util"
+
+# Set traps to kill our subprocesses when we exit (for any reason).
+trap ":" TERM      # Avoid the EXIT handler from killing bash.
+trap "exit 2" INT  # Ctrl-C, make sure we fail in that case.
+trap "kill 0" EXIT # Kill children on exit.
+
+# The tests are run from the test root.
+cd "$(realpath `dirname ${0}`)/"
+
+# Build the binaries.
+if [ "$COVER_DIR" != "" ]; then
+	(
+		cd ..
+		go test -covermode=count -coverpkg=./... -c -tags coveragebin
+		mv gofer.test gofer
+	)
+else
+	( cd ..; go build )
+fi
+( cd util; go build exp.go )
+
+
+# Run gofer in the background (sets $PID to its process id).
+function gofer() {
+	# Set the coverage arguments each time, as we don't want the different
+	# runs to override the generated profile.
+	if [ "$COVER_DIR" != "" ]; then
+		COVER_ARGS="-test.run=^TestRunMain$ \
+			-test.coverprofile=$COVER_DIR/it-`date +%s.%N`.out"
+	fi
+
+	$SYSTEMD_ACTIVATE ../gofer $COVER_ARGS \
+		-v=3 \
+		"$@" >> .out.log 2>&1 &
+	PID=$!
+}
+
+# Wait until there's something listening on the given port.
+function wait_until_ready() {
+	PORT=$1
+
+	while ! bash -c "true < /dev/tcp/localhost/$PORT" 2>/dev/null ; do
+		sleep 0.01
+	done
+}
+
+function generate_certs() {
+	mkdir -p .certs/localhost
+	(
+		cd .certs/localhost
+		go run ${UTILDIR}/generate_cert.go \
+			-ca -duration=1h --host=localhost
+	)
+}
+
+function curl() {
+	curl --cacert ".certs/localhost/fullchain.pem" "$@"
+}
+
+function exp() {
+	if [ "$V" == "1" ]; then
+		VF="-v"
+	fi
+	echo "  $@"
+	${UTILDIR}/exp "$@" \
+		$VF \
+		-cacert=".certs/localhost/fullchain.pem"
+}
+
+function snoop() {
+	if [ "$SNOOP" == "1" ]; then
+		read -p"Press enter to continue"
+	fi
+}
+
+echo "## Setup"
+
+# Launch the backend serving static files and CGI.
+gofer -logfile=.01-be.log -configfile=01-be.conf
+DIR_PID=$PID
+wait_until_ready 8450
+
+# Launch the test instance.
+generate_certs
+gofer -logfile=.01-fe.log -configfile=01-fe.conf
+wait_until_ready 8441  # http
+wait_until_ready 8442  # https
+wait_until_ready 8445  # raw
+
+snoop
+
+#
+# Test cases.
+#
+echo "## Tests"
+
+# Common tests, for both servers.
+for base in \
+	http://localhost:8441 \
+	https://localhost:8442 ;
+do
+	exp $base/file -body "ñaca\n"
+
+	exp $base/dir -status 301 -redir /dir/
+	exp $base/dir/ -bodyre '<a href="%C3%B1aca">ñaca</a>'
+	exp $base/dir/hola -body 'hola marola\n'
+	exp $base/dir/ñaca -body "tracañaca\n"
+
+	exp $base/cgi/ -bodyre '"param 1" "param 2"'
+	exp "$base/cgi/?cucu=melo&a;b" -bodyre 'QUERY_STRING=cucu=melo&a;b\n'
+
+	exp $base/gogo/ -status 307 -redir https://google.com/
+	exp $base/gogo/gaga -status 307 -redir https://google.com/gaga
+	exp $base/gogo/a/b/ -status 307 -redir https://google.com/a/b/
+
+	exp $base/bad/unreacheable -status 502
+	exp $base/bad/empty -status 502
+
+	# Test that the FE doesn't forward this - it exists on the BE, but the
+	# route doesn't end in a / so it shouldn't be forwarded.
+	exp $base/file/second -status 404
+
+	# Interesting case because neither has a trailing "/", so check that
+	# the striping is done correctly.
+	exp $base/file/ -status 404
+done
+
+# HTTPS-only tests.
+exp https://localhost:8442/dar/ -bodyre '<a href="%C3%B1aca">ñaca</a>'
+
+# We rely on the BE having this, so check to avoid false positives due to
+# misconfiguration.
+exp http://localhost:8450/file/second -body "tracañaca\n"
+
+# Raw proxying.
+exp http://localhost:8445/file -body "ñaca\n"
+exp https://localhost:8446/file -body "ñaca\n"
+
+snoop
diff --git a/test/testdata/cgi.sh b/test/testdata/cgi.sh
new file mode 100755
index 0000000..6227519
--- /dev/null
+++ b/test/testdata/cgi.sh
@@ -0,0 +1,16 @@
+#!/bin/bash
+
+set -e
+
+echo "Content-type: text/plain"
+echo
+
+echo -n ARGS:
+for i in "$@"; do
+	echo -n " \"$i\""
+done
+echo
+
+echo
+env | sort
+
diff --git a/test/testdata/dir/hola b/test/testdata/dir/hola
new file mode 100644
index 0000000..f7d6dee
--- /dev/null
+++ b/test/testdata/dir/hola
@@ -0,0 +1 @@
+hola marola
diff --git "a/test/testdata/dir/\303\261aca" "b/test/testdata/dir/\303\261aca"
new file mode 100644
index 0000000..bd8194c
--- /dev/null
+++ "b/test/testdata/dir/\303\261aca"
@@ -0,0 +1 @@
+tracañaca
diff --git a/test/testdata/file b/test/testdata/file
new file mode 100644
index 0000000..e94300e
--- /dev/null
+++ b/test/testdata/file
@@ -0,0 +1 @@
+ñaca
diff --git a/test/util/exp.go b/test/util/exp.go
new file mode 100644
index 0000000..530a0ce
--- /dev/null
+++ b/test/util/exp.go
@@ -0,0 +1,136 @@
+package main
+
+import (
+	"crypto/tls"
+	"crypto/x509"
+	"flag"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"os"
+	"regexp"
+	"sort"
+	"strconv"
+	"strings"
+)
+
+var exitCode int = 0
+
+func main() {
+	// The first arg is the URL, and then we shift.
+	url := os.Args[1]
+	os.Args = append([]string{os.Args[0]}, os.Args[2:]...)
+
+	var (
+		body = flag.String("body", "",
+			"expect body with these exact contents")
+		bodyRE = flag.String("bodyre", "",
+			"expect body matching these contents (regexp match)")
+		redir = flag.String("redir", "",
+			"expect a redirect to this URL")
+		status = flag.Int("status", 200,
+			"expect this status code")
+		verbose = flag.Bool("v", false,
+			"enable verbose output")
+		caCert = flag.String("cacert", "",
+			"file to read CA cert from")
+	)
+	flag.Parse()
+
+	client := &http.Client{
+		CheckRedirect: noRedirect,
+		Transport:     mkTransport(*caCert),
+	}
+
+	resp, err := client.Get(url)
+	if err != nil {
+		fatalf("error getting %q: %v\n", url, err)
+	}
+	defer resp.Body.Close()
+	rbody, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		errorf("error reading body: %v\n", err)
+	}
+
+	if *verbose {
+		fmt.Printf("Request: %s\n", url)
+		fmt.Printf("Response:\n")
+		fmt.Printf("  %v  %v\n", resp.Proto, resp.Status)
+		ks := []string{}
+		for k, _ := range resp.Header {
+			ks = append(ks, k)
+		}
+		sort.Strings(ks)
+		for _, k := range ks {
+			fmt.Printf("  %v: %s\n", k,
+				strings.Join(resp.Header.Values(k), ", "))
+		}
+		fmt.Printf("\n")
+	}
+
+	if resp.StatusCode != *status {
+		errorf("status is not %d: %q\n", *status, resp.Status)
+	}
+
+	if *body != "" {
+		// Unescape the body to allow control characters more easily.
+		*body, _ = strconv.Unquote("\"" + *body + "\"")
+		if string(rbody) != *body {
+			errorf("unexpected body: %q\n", rbody)
+		}
+	}
+
+	if *bodyRE != "" {
+		matched, err := regexp.Match(*bodyRE, rbody)
+		if err != nil {
+			errorf("regexp error: %q", err)
+		}
+		if !matched {
+			errorf("body did not match regexp: %q\n", rbody)
+		}
+	}
+
+	if *redir != "" {
+		if loc := resp.Header.Get("Location"); loc != *redir {
+			errorf("unexpected redir location: %q\n", loc)
+		}
+	}
+
+	os.Exit(exitCode)
+}
+
+func noRedirect(req *http.Request, via []*http.Request) error {
+	return http.ErrUseLastResponse
+}
+
+func mkTransport(caCert string) *http.Transport {
+	if caCert == "" {
+		return nil
+	}
+
+	certs, err := ioutil.ReadFile(caCert)
+	if err != nil {
+		fatalf("error reading CA file %q: %v", caCert, err)
+	}
+
+	rootCAs := x509.NewCertPool()
+	if ok := rootCAs.AppendCertsFromPEM(certs); !ok {
+		fatalf("error adding certs to root")
+	}
+
+	return &http.Transport{
+		TLSClientConfig: &tls.Config{
+			RootCAs: rootCAs,
+		},
+	}
+}
+
+func fatalf(s string, a ...interface{}) {
+	fmt.Fprintf(os.Stderr, s, a...)
+	os.Exit(1)
+}
+
+func errorf(s string, a ...interface{}) {
+	fmt.Fprintf(os.Stderr, s, a...)
+	exitCode = 1
+}
diff --git a/test/util/generate_cert.go b/test/util/generate_cert.go
new file mode 100644
index 0000000..4fbf706
--- /dev/null
+++ b/test/util/generate_cert.go
@@ -0,0 +1,123 @@
+// Copyright 2009 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// +build ignore
+
+// Generate a self-signed X.509 certificate for a TLS server. Outputs to
+// 'cert.pem' and 'key.pem' and will overwrite existing files.
+
+package main
+
+import (
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/pem"
+	"flag"
+	"fmt"
+	"log"
+	"math/big"
+	"net"
+	"os"
+	"strings"
+	"time"
+
+	"golang.org/x/net/idna"
+)
+
+var (
+	host      = flag.String("host", "", "Comma-separated hostnames and IPs to generate a certificate for")
+	validFrom = flag.String("start-date", "", "Creation date formatted as Jan 1 15:04:05 2011")
+	validFor  = flag.Duration("duration", 365*24*time.Hour, "Duration that certificate is valid for")
+	isCA      = flag.Bool("ca", false, "whether this cert should be its own Certificate Authority")
+)
+
+func main() {
+	flag.Parse()
+
+	if len(*host) == 0 {
+		log.Fatalf("Missing required --host parameter")
+	}
+
+	priv, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		log.Fatalf("failed to generate private key: %s", err)
+	}
+
+	var notBefore time.Time
+	if len(*validFrom) == 0 {
+		notBefore = time.Now()
+	} else {
+		notBefore, err = time.Parse("Jan 2 15:04:05 2006", *validFrom)
+		if err != nil {
+			fmt.Fprintf(os.Stderr, "Failed to parse creation date: %s\n", err)
+			os.Exit(1)
+		}
+	}
+
+	notAfter := notBefore.Add(*validFor)
+
+	serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
+	serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
+	if err != nil {
+		log.Fatalf("failed to generate serial number: %s", err)
+	}
+
+	template := x509.Certificate{
+		SerialNumber: serialNumber,
+		Subject: pkix.Name{
+			Organization: []string{"Acme Co"},
+		},
+		NotBefore: notBefore,
+		NotAfter:  notAfter,
+
+		KeyUsage:              x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
+		ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+		BasicConstraintsValid: true,
+	}
+
+	hosts := strings.Split(*host, ",")
+	for _, h := range hosts {
+		if ip := net.ParseIP(h); ip != nil {
+			template.IPAddresses = append(template.IPAddresses, ip)
+		} else {
+			// We use IDNA-encoded DNS names, otherwise the TLS library won't
+			// load the certificates.
+			ih, err := idna.ToASCII(h)
+			if err != nil {
+				log.Fatalf("host %q cannot be IDNA-encoded: %v", h, err)
+			}
+			template.DNSNames = append(template.DNSNames, ih)
+		}
+	}
+
+	if *isCA {
+		template.IsCA = true
+		template.KeyUsage |= x509.KeyUsageCertSign
+	}
+
+	derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
+	if err != nil {
+		log.Fatalf("Failed to create certificate: %s", err)
+	}
+
+	certOut, err := os.Create("fullchain.pem")
+	if err != nil {
+		log.Fatalf("failed to open fullchain.pem for writing: %s", err)
+	}
+	pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
+	certOut.Close()
+
+	keyOut, err := os.OpenFile("privkey.pem", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
+	if err != nil {
+		log.Fatalf("failed to open privkey.pem for writing: %s", err)
+		return
+	}
+
+	block := &pem.Block{Type: "RSA PRIVATE KEY",
+		Bytes: x509.MarshalPKCS1PrivateKey(priv)}
+	pem.Encode(keyOut, block)
+	keyOut.Close()
+}