Files
smallwebwaf/internal/rproxy/rproxy_test.go
T
sneak 4a030557f3
check / check (push) Failing after 2m47s
Rename the program to sww and its deciding daemon to discriminator (closes #218)
The program is now sww, built from cmd/sww, with two modes: sww rproxy and
sww discriminator. servewaf becomes discriminator everywhere it names that
daemon: the package (internal/discriminator), its types, functions, files, the
discriminator_line log field, and its docs and tests.

smallwebwaf is left only where sneak's directive allows it: the documents'
product name, the git.eeqj.de/sneak/smallwebwaf URLs, and the Go module path
sneak.berlin/go/smallwebwaf. Everywhere else a name containing it takes the sww
form: the package (internal/sww), metric names (sww_*), the X-Sww-* headers,
the /_sww/ admin endpoints, the OS user, and the state and rules directories.
The SWWAF_ environment variables take the SWW_ form.

No logic changes. make check passes.

Model: deepseek-v4-pro
2026-11-07 05:27:58 +00:00

1765 lines
46 KiB
Go

package rproxy_test
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"io"
"log/slog"
"maps"
"net"
"net/http"
"net/http/httptest"
"slices"
"strconv"
"strings"
"sync"
"testing"
"time"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rproxy"
)
const (
// waitLimit bounds how long a test waits for what should happen.
waitLimit = 10 * time.Second
// pollInterval is how often a test looks for a log line.
pollInterval = 10 * time.Millisecond
// localhost is where every test server listens, and so the address rproxy
// sees each test's requests come from.
localhost = "127.0.0.1"
// site is the host the tests' requests ask for.
site = "site.example"
// greeting is what the tests' app answers by default.
greeting = "hello from the app"
// nowhere is a URL nothing listens at: port 9 is the discard service, which
// nothing on a test host runs.
nowhere = "http://127.0.0.1:9"
)
// The settings the tests set.
const (
timeoutSetting = "SWRP_TIMEOUT_MS"
defaultDisposition = "SWRP_DEFAULT_DISPOSITION"
trustedProxies = "SWRP_TRUSTED_PROXIES"
maxRequestSize = "SWRP_MAX_REQUEST_SIZE"
maxResponseSize = "SWRP_MAX_RESPONSE_SIZE"
maxRequestTime = "SWRP_MAX_REQUEST_TIME_MS"
maxResponseTime = "SWRP_MAX_RESPONSE_TIME_MS"
)
// The headers the tests set and look at.
const (
dispositionHeader = discriminator.DispositionHeader
forwardedForHeader = "X-Forwarded-For"
forwardedProtoHeader = "X-Forwarded-Proto"
requestIDHeader = "X-Request-ID"
asnHeader = "X-Client-Asn"
countryHeader = "X-Client-Country"
contentLengthHeader = "Content-Length"
// fromDiscriminatorHeader is a header the stand-in for discriminator adds to a deny.
fromDiscriminatorHeader = "X-From-Discriminator"
// addedHeaderValue is the value of a header a test adds to an answer, to see it
// reach the visitor.
addedHeaderValue = "yes"
)
// What the stand-in for discriminator answers.
const (
dispositionAllow = discriminator.DispositionAllow
dispositionDeny = discriminator.DispositionDeny
dispositionClose = discriminator.DispositionClose
// deniedBody is the body of a deny.
deniedBody = "refused by discriminator"
)
// output collects what rproxy writes on stdout.
type output struct {
mu sync.Mutex
buf bytes.Buffer
}
// Write adds lines rproxy writes.
func (o *output) Write(p []byte) (int, error) {
o.mu.Lock()
defer o.mu.Unlock()
return o.buf.Write(p)
}
// requestLine waits for the request log line of a test's one request.
func (o *output) requestLine(t *testing.T) map[string]any {
t.Helper()
deadline := time.Now().Add(waitLimit)
for time.Now().Before(deadline) {
o.mu.Lock()
text := o.buf.String()
o.mu.Unlock()
for line := range strings.Lines(text) {
var fields map[string]any
err := json.Unmarshal([]byte(line), &fields)
if err != nil {
t.Fatalf("output line %q is not JSON: %v", line, err)
}
if fields["type"] == "request" {
return fields
}
}
time.Sleep(pollInterval)
}
t.Fatal("no request log line")
return nil
}
// has reports whether what rproxy has written so far holds text.
func (o *output) has(text string) bool {
o.mu.Lock()
defer o.mu.Unlock()
return strings.Contains(o.buf.String(), text)
}
// waitFor waits until what rproxy has written holds text.
func (o *output) waitFor(t *testing.T, text string) {
t.Helper()
deadline := time.Now().Add(waitLimit)
for time.Now().Before(deadline) {
if o.has(text) {
return
}
time.Sleep(pollInterval)
}
t.Fatalf("rproxy did not write %q", text)
}
// heldLog is a request log whose Write waits until release is closed.
type heldLog struct {
release chan struct{}
}
// Write waits until release is closed.
func (l heldLog) Write(p []byte) (int, error) {
<-l.release
return len(p), nil
}
// question is what the stand-in for discriminator received as a question.
type question struct {
method, target, host string
header http.Header
body []byte
contentLength int64
transferEncoding []string
}
// Discriminator is a stand-in for discriminator that answers each question as
// answer does, and keeps the questions and the reports it gets.
type Discriminator struct {
*httptest.Server
questions chan question
reports chan discriminator.Report
}
// startDiscriminator starts a stand-in for discriminator.
func startDiscriminator(t *testing.T, answer http.HandlerFunc) *Discriminator {
t.Helper()
s := &Discriminator{
questions: make(chan question, 10),
reports: make(chan discriminator.Report, 10),
}
s.Server = httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(discriminator.ClientHeader) == "" {
s.takeReport(t, w, r)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read the question's body: %v", err)
}
s.questions <- question{
r.Method, r.RequestURI, r.Host, r.Header.Clone(), body, r.ContentLength,
r.TransferEncoding,
}
answer(w, r)
}))
t.Cleanup(s.Close)
return s
}
// takeReport keeps a report and answers it as discriminator does.
func (s *Discriminator) takeReport(
t *testing.T, w http.ResponseWriter, r *http.Request,
) {
t.Helper()
var report discriminator.Report
err := json.NewDecoder(r.Body).Decode(&report)
if err != nil || r.Method != http.MethodPost ||
r.URL.Path != discriminator.ReportPath {
t.Errorf("%s %s is no report: %v", r.Method, r.URL.Path, err)
}
w.WriteHeader(http.StatusNoContent)
s.reports <- report
}
// wantNoReport checks that no report comes for a moment.
func (s *Discriminator) wantNoReport(t *testing.T) {
t.Helper()
select {
case report := <-s.reports:
t.Errorf("report %+v, want none", report)
case <-time.After(10 * pollInterval):
}
}
// report waits for a report.
func (s *Discriminator) report(t *testing.T) discriminator.Report {
t.Helper()
select {
case report := <-s.reports:
return report
case <-time.After(waitLimit):
t.Fatal("no report")
return discriminator.Report{}
}
}
// question waits for a question.
func (s *Discriminator) question(t *testing.T) question {
t.Helper()
select {
case asked := <-s.questions:
return asked
case <-time.After(waitLimit):
t.Fatal("no question")
return question{}
}
}
// startDiscriminatorAnsweringReports starts a stand-in for discriminator that
// answers each question as answer does and each report as report does, and
// returns its URL.
func startDiscriminatorAnsweringReports(
t *testing.T, answer, report http.HandlerFunc,
) string {
t.Helper()
backend := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(discriminator.ClientHeader) != "" {
answer(w, r)
return
}
report(w, r)
}))
t.Cleanup(backend.Close)
return backend.URL
}
// allow answers a question with allow.
func allow(w http.ResponseWriter, _ *http.Request) {
w.Header().Set(dispositionHeader, dispositionAllow)
w.WriteHeader(http.StatusOK)
}
// deny answers a question with deny, status 403 and a body of its own.
func deny(w http.ResponseWriter, _ *http.Request) {
w.Header().Set(dispositionHeader, dispositionDeny)
w.Header().Set(fromDiscriminatorHeader, addedHeaderValue)
w.WriteHeader(http.StatusForbidden)
_, _ = io.WriteString(w, deniedBody)
}
// startApp starts app as the app rproxy passes requests to.
func startApp(t *testing.T, app http.HandlerFunc) *httptest.Server {
t.Helper()
server := httptest.NewServer(app)
t.Cleanup(server.Close)
return server
}
// greet is an app that reads the request's body and answers greeting.
func greet(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(io.Discard, r.Body)
_, _ = io.WriteString(w, greeting)
}
// startRProxy starts rproxy with discriminator at backend and the app at appURL, and
// the settings in env on top of the defaults. It returns where rproxy listens
// and what it writes.
func startRProxy(
t *testing.T, backend, appURL string, env map[string]string,
) (string, *output) {
t.Helper()
addr, out, _ := startServer(t, backend, appURL, env)
return addr, out
}
// startServer is startRProxy, and returns the server as well.
func startServer(
t *testing.T, backend, appURL string, env map[string]string,
) (string, *output, *rproxy.Server) {
t.Helper()
out := &output{}
addr, server := startServerLogging(t, backend, appURL, env, out, out)
return addr, out, server
}
// startServerLogging is startServer with rproxy's request lines going to
// requestLog and its process lines to processLog.
func startServerLogging(
t *testing.T, backend, appURL string, env map[string]string,
requestLog, processLog io.Writer,
) (string, *rproxy.Server) {
t.Helper()
settings := map[string]string{
"SWRP_WAF_BACKEND": backend, "SWRP_UPSTREAM_URL": appURL,
}
maps.Copy(settings, env)
cfg, err := config.RProxyFromEnvironment(func(name string) (string, bool) {
value, ok := settings[name]
return value, ok
})
if err != nil {
t.Fatalf("settings %v: %v", settings, err)
}
server := rproxy.New(rproxy.Params{
Config: cfg,
RequestLog: requestLog,
ProcessLog: requestlog.NewProcessLogger(processLog, "test", slog.LevelInfo),
})
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
if err != nil {
t.Fatalf("listen: %v", err)
}
go func() {
_ = server.Serve(listener)
}()
t.Cleanup(func() {
_ = server.Close()
server.WaitForReports()
})
return listener.Addr().String(), server
}
// answered is the visitor's answer.
type answered struct {
status int
header http.Header
body string
}
// visit sends req as a visitor, on a connection of its own, and returns the
// answer.
func visit(t *testing.T, req *http.Request) answered {
t.Helper()
transport := &http.Transport{DisableKeepAlives: true, DisableCompression: true}
defer transport.CloseIdleConnections()
res, err := transport.RoundTrip(req)
if err != nil {
t.Fatalf("request: %v", err)
}
defer func() {
_ = res.Body.Close()
}()
body, err := io.ReadAll(res.Body)
if err != nil {
t.Fatalf("read the answer: %v", err)
}
return answered{res.StatusCode, res.Header, string(body)}
}
// newRequest returns a request for target at addr.
func newRequest(
t *testing.T, method, addr, target string, body io.Reader,
) *http.Request {
t.Helper()
req, err := http.NewRequestWithContext(t.Context(), method, "http://"+addr+target,
body)
if err != nil {
t.Fatalf("new request: %v", err)
}
return req
}
// get sends a GET for target at addr, and returns the answer.
func get(t *testing.T, addr, target string) answered {
t.Helper()
return visit(t, newRequest(t, http.MethodGet, addr, target, http.NoBody))
}
// wantGreeting checks that the answer is the app's.
func wantGreeting(t *testing.T, got answered) {
t.Helper()
if got.status != http.StatusOK || got.body != greeting {
t.Errorf("answered %d %q, want the app's greeting", got.status, got.body)
}
}
// dial opens a connection to addr, closed as the test ends.
func dial(t *testing.T, addr string) net.Conn {
t.Helper()
conn, err := (&net.Dialer{}).DialContext(t.Context(), "tcp", addr)
if err != nil {
t.Fatalf("dial: %v", err)
}
t.Cleanup(func() {
_ = conn.Close()
})
err = conn.SetDeadline(time.Now().Add(waitLimit))
if err != nil {
t.Fatalf("set the deadline: %v", err)
}
return conn
}
// send writes text to conn.
func send(t *testing.T, conn net.Conn, text string) {
t.Helper()
_, err := io.WriteString(conn, text)
if err != nil {
t.Fatalf("send: %v", err)
}
}
// readStatus reads the answer's status from conn.
func readStatus(t *testing.T, conn net.Conn) int {
t.Helper()
res, err := http.ReadResponse(bufio.NewReader(conn), nil)
if err != nil {
t.Fatalf("read the answer: %v", err)
}
_ = res.Body.Close()
return res.StatusCode
}
func TestPassesAnAllowedRequestToTheAppAndItsAnswerBack(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set(asnHeader, "AS64496")
w.Header().Set(countryHeader, "DE")
allow(w, r)
})
seen := make(chan *http.Request, 1)
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
seen <- r.Clone(t.Context())
w.Header().Set("X-App", addedHeaderValue)
w.WriteHeader(http.StatusTeapot)
_, _ = io.WriteString(w, greeting)
})
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
const target = pathGoReencodes + "?b=2&a=1&bad=%zz;x"
req := newRequest(t, http.MethodGet, addr, target, http.NoBody)
req.URL.Opaque = pathGoReencodes
req.Host = site
req.Header.Set(asnHeader, "AS1")
req.Header.Set(countryHeader, "XX")
req.Header.Set(requestIDHeader, "the-visitor's")
got := visit(t, req)
if got.status != http.StatusTeapot || got.body != greeting ||
got.header.Get("X-App") != addedHeaderValue {
t.Errorf("answered %+v, want the app's answer", got)
}
received := <-seen
asked := standIn.question(t)
if received.RequestURI != target || received.Host != site {
t.Errorf("the app saw %s for %s", received.RequestURI, received.Host)
}
for name, want := range map[string]string{
asnHeader: "AS64496",
countryHeader: "DE",
forwardedForHeader: localhost,
requestIDHeader: asked.header.Get(requestIDHeader),
} {
if received.Header.Get(name) != want {
t.Errorf("the app saw %s %q, want %q", name, received.Header.Get(name), want)
}
}
}
func TestAPeerNotTrustedHasItsForwardedHeadersReplaced(t *testing.T) {
t.Parallel()
removed := []string{
"X-Forwarded-Port", "X-Forwarded-Server", "X-Forwarded-Uri",
"X-Forwarded-Method", "X-Forwarded-Prefix", "X-Forwarded-Tls-Client-Cert",
"X-Forwarded-Tls-Client-Cert-Info", "X-Real-Ip",
}
standIn := startDiscriminator(t, allow)
seen := make(chan http.Header, 1)
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
seen <- r.Header.Clone()
greet(w, r)
})
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
trustedProxies: "10.0.0.0/8",
})
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
req.Host = site
req.Header.Set(forwardedForHeader, "198.51.100.1")
req.Header.Set("X-Forwarded-Host", "claimed.example")
req.Header.Set(forwardedProtoHeader, "https")
for _, name := range removed {
req.Header.Set(name, "claimed")
}
wantGreeting(t, visit(t, req))
header := <-seen
for name, want := range map[string]string{
forwardedForHeader: localhost,
"X-Forwarded-Host": site,
forwardedProtoHeader: "http",
} {
if got := header.Values(name); !slices.Equal(got, []string{want}) {
t.Errorf("the app saw %s %q, want %q", name, got, want)
}
}
for _, name := range removed {
if got := header.Values(name); len(got) != 0 {
t.Errorf("the app saw %s %q, want none", name, got)
}
}
}
func TestATrustedProxysForwardedHeadersPassOn(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
seen := make(chan http.Header, 1)
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
seen <- r.Header.Clone()
greet(w, r)
})
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
trustedProxies: localhost + "/32",
})
sent := map[string]string{
"Forwarded": "for=198.51.100.1",
"X-Forwarded-Host": "public.example",
forwardedProtoHeader: "https",
"X-Real-Ip": "198.51.100.1",
}
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
req.Host = site
for name, value := range sent {
req.Header.Set(name, value)
}
wantGreeting(t, visit(t, req))
header := <-seen
for name, want := range sent {
if got := header.Values(name); !slices.Equal(got, []string{want}) {
t.Errorf("the app saw %s %q, want %q", name, got, want)
}
}
}
func TestAnAnswerWithoutAContentTypeGetsNone(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header()["Content-Type"] = nil
_, _ = io.WriteString(w, "<html>")
})
_, _, server := startServer(t, standIn.URL, app.URL, nil)
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
w := httptest.NewRecorder()
server.Handler.ServeHTTP(w, req)
// Go's server guesses a type when the first part of the body goes before the
// headers are flushed, which is a matter of timing here. So this looks for what
// stops it: the key with no value.
values, there := w.Header()["Content-Type"]
if !there || len(values) != 0 {
t.Errorf("the answer's Content-Type is %q, there: %v, want the key with no value",
values, there)
}
}
func TestRemovesTheVisitorsLookupHeadersWhenDiscriminatorGivesNone(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
backend func(t *testing.T) string
}{
{"discriminator allows", func(t *testing.T) string {
t.Helper()
return startDiscriminator(t, allow).URL
}},
{"discriminator is down", func(*testing.T) string { return nowhere }},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
seen := make(chan http.Header, 1)
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
seen <- r.Header.Clone()
greet(w, r)
})
addr, _ := startRProxy(t, tc.backend(t), app.URL, nil)
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
req.Header.Set(asnHeader, "AS1")
req.Header.Set(countryHeader, "XX")
wantGreeting(t, visit(t, req))
header := <-seen
if len(header.Values(asnHeader)) != 0 || len(header.Values(countryHeader)) != 0 {
t.Errorf("the app saw the visitor's lookup headers: %v", header)
}
})
}
}
func TestDenyGivesTheVisitorDiscriminatorsAnswer(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, deny)
app := startApp(t, func(http.ResponseWriter, *http.Request) {
t.Error("the app was asked")
})
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
got := get(t, addr, "/")
if got.status != http.StatusForbidden || got.body != deniedBody ||
got.header.Get(fromDiscriminatorHeader) != addedHeaderValue {
t.Errorf("answered %+v, want discriminator's answer", got)
}
if len(got.header.Values(dispositionHeader)) != 0 {
t.Errorf("the visitor got %s", dispositionHeader)
}
report := standIn.report(t)
if report.Status != http.StatusForbidden ||
report.ResponseBytes != int64(len(deniedBody)) ||
report.DurationUpstreamTotal != 0 {
t.Errorf("report %+v, of a request denied", report)
}
line := out.requestLine(t)
if line["disposition"] != dispositionDeny || line["default_disposition"] != nil {
t.Errorf("line %v, want discriminator's deny", line)
}
}
func TestADeniedHeadKeepsDiscriminatorsContentLength(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, deny)
app := startApp(t, greet)
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
got := visit(t, newRequest(t, http.MethodHead, addr, "/", http.NoBody))
if got.status != http.StatusForbidden ||
got.header.Get(contentLengthHeader) != strconv.Itoa(len(deniedBody)) {
t.Errorf("answered %+v, want discriminator's 403 and its length", got)
}
}
func TestAnAnswerRProxyHoldsWholeIsSentBeforeItsLogLine(t *testing.T) {
t.Parallel()
const host = " HTTP/1.1\r\nHost: site.example\r\n\r\n"
for _, tc := range []struct {
name, request, body string
status int
}{
{"a deny", "GET /" + host, deniedBody, http.StatusForbidden},
{"rproxy's own answer", "GET x:admin" + host, "Bad Request\n", http.StatusBadRequest},
{"the health check", "GET " + discriminator.HealthPath + host, "ok\n", http.StatusOK},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
log := heldLog{release: make(chan struct{})}
standIn := startDiscriminator(t, deny)
app := startApp(t, greet)
addr, _ := startServerLogging(t, standIn.URL, app.URL, nil, log, io.Discard)
t.Cleanup(func() { close(log.release) })
conn := dial(t, addr)
send(t, conn, tc.request)
res, err := http.ReadResponse(bufio.NewReader(conn), nil)
if err != nil {
t.Fatalf("read the answer: %v", err)
}
defer func() {
_ = res.Body.Close()
}()
body, err := io.ReadAll(res.Body)
if err != nil || res.StatusCode != tc.status || string(body) != tc.body {
t.Errorf("answered %d %q (%v), want %d %q", res.StatusCode, body, err,
tc.status, tc.body)
}
})
}
}
func TestRProxysOwnAnswerIsPlainText(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
app := startApp(t, greet)
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
conn := dial(t, addr)
send(t, conn, "GET x:admin HTTP/1.1\r\nHost: site.example\r\n\r\n")
res, err := http.ReadResponse(bufio.NewReader(conn), nil)
if err != nil {
t.Fatalf("read the answer: %v", err)
}
_ = res.Body.Close()
if res.Header.Get("Content-Type") != "text/plain; charset=utf-8" ||
res.Header.Get("X-Content-Type-Options") != "nosniff" {
t.Errorf("the answer's headers are %v", res.Header)
}
}
func TestCloseClosesTheConnectionWithoutAnAnswer(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set(dispositionHeader, dispositionClose)
w.WriteHeader(http.StatusOK)
})
app := startApp(t, func(http.ResponseWriter, *http.Request) {
t.Error("the app was asked")
})
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
conn := dial(t, addr)
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
text, err := io.ReadAll(conn)
if err != nil || len(text) != 0 {
t.Errorf("read %q (%v), want the connection closed without an answer",
text, err)
}
report := standIn.report(t)
if report.Status != 0 || report.ResponseBytes != 0 {
t.Errorf("report %+v, of a visitor sent nothing", report)
}
line := out.requestLine(t)
if line["disposition"] != dispositionClose || line["status"] != 0.0 {
t.Errorf("line %v, want close and status 0", line)
}
}
func TestALineForARequestClosedWithoutAnAnswerHasNoNegativeTime(t *testing.T) {
t.Parallel()
backend := startDiscriminatorAnsweringReports(t,
func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set(dispositionHeader, dispositionClose)
},
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
// A negative time would be about a microsecond, which one request would seldom
// show.
for range 100 {
addr, out := startRProxy(t, backend, nowhere, nil)
conn := dial(t, addr)
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
line := out.requestLine(t)
duration, ok := line["duration_response"].(float64)
if !ok || duration < 0 {
t.Errorf("line %v, want a duration_response that is not negative", line)
}
}
}
// answeringWith returns what starts a stand-in for discriminator that answers each
// question as answer does, and returns its URL.
func answeringWith(answer http.HandlerFunc) func(t *testing.T) string {
return func(t *testing.T) string {
t.Helper()
return startDiscriminator(t, answer).URL
}
}
// noAnswers are the ways discriminator can leave the decision to the default.
func noAnswers() map[string]func(t *testing.T) string {
return map[string]func(t *testing.T) string{
"no connection": func(*testing.T) string { return nowhere },
"no answer at all": answeringWith(func(http.ResponseWriter, *http.Request) {
panic(http.ErrAbortHandler)
}),
"a slow start": answeringWith(func(_ http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
}),
"headers followed by a stalled body": answeringWith(
func(w http.ResponseWriter, r *http.Request) {
w.Header().Set(dispositionHeader, dispositionDeny)
w.WriteHeader(http.StatusForbidden)
_ = http.NewResponseController(w).Flush()
<-r.Context().Done()
}),
"an answer cut short": answeringWith(
func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set(dispositionHeader, dispositionDeny)
w.Header().Set(contentLengthHeader, "100")
w.WriteHeader(http.StatusForbidden)
_, _ = io.WriteString(w, deniedBody)
// Go's server drops on this panic what it has not flushed.
_ = http.NewResponseController(w).Flush()
panic(http.ErrAbortHandler)
}),
}
}
func TestAnAnswerOtherThanAllowGoesToTheVisitorAsDiscriminatorGaveIt(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name, disposition, body string
status int
}{
{"a refusal", dispositionDeny, deniedBody, http.StatusForbidden},
{"a refusal, 429", dispositionDeny, deniedBody, http.StatusTooManyRequests},
{"discriminator's 400", "", "refused\n", http.StatusBadRequest},
{"a 500", "", "broken\n", http.StatusInternalServerError},
{"a 503 denied", dispositionDeny, "busy\n", http.StatusServiceUnavailable},
{"no header", "", "who knows\n", http.StatusOK},
{"an unknown disposition", "maybe", "who knows\n", http.StatusOK},
} {
for _, setting := range []string{dispositionAllow, dispositionDeny} {
t.Run(tc.name+" with the default "+setting, func(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
if tc.disposition != "" {
w.Header().Set(dispositionHeader, tc.disposition)
}
w.Header().Set(fromDiscriminatorHeader, addedHeaderValue)
w.WriteHeader(tc.status)
_, _ = io.WriteString(w, tc.body)
})
app := startApp(t, func(http.ResponseWriter, *http.Request) {
t.Error("the app was asked")
})
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
defaultDisposition: setting,
})
got := get(t, addr, "/")
if got.status != tc.status || got.body != tc.body ||
got.header.Get(fromDiscriminatorHeader) != addedHeaderValue {
t.Errorf("answered %+v, want discriminator's answer", got)
}
if len(got.header.Values(dispositionHeader)) != 0 {
t.Errorf("the visitor got %s", dispositionHeader)
}
line := out.requestLine(t)
if line["status"] != float64(tc.status) || line["default_disposition"] != nil {
t.Errorf("line %v, want status %d and no default", line, tc.status)
}
if out.has("discriminator gave no answer") {
t.Error("rproxy warned that discriminator gave no answer")
}
})
}
}
}
func TestAnAnswerWithoutAValidDispositionGetsNoReport(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "refused", http.StatusBadRequest)
})
app := startApp(t, greet)
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
if got := get(t, addr, "/"); got.status != http.StatusBadRequest {
t.Errorf("answered %+v, want discriminator's 400", got)
}
if line := out.requestLine(t); line["disposition"] != nil {
t.Errorf("line %v, want no disposition", line)
}
standIn.wantNoReport(t)
}
func TestAnAnswerWithoutAContentTypeGoesToTheVisitorWithNone(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header()["Content-Type"] = nil
w.WriteHeader(http.StatusInternalServerError)
_, _ = io.WriteString(w, "<html>broken")
})
addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
got := get(t, addr, "/")
if contentType, sent := got.header["Content-Type"]; sent {
t.Errorf("the visitor got the Content-Type %v", contentType)
}
}
func TestAnAnswerOf1MiBGoesToTheVisitor(t *testing.T) {
t.Parallel()
const size = 1 << 20
standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write(make([]byte, size))
})
addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
got := get(t, addr, "/")
if got.status != http.StatusInternalServerError || len(got.body) != size {
t.Errorf("answered %d with %d bytes, want discriminator's 500 with %d",
got.status, len(got.body), size)
}
}
// startDiscriminatorWriting starts a stand-in for discriminator that reads
// each question and answers it with text, as it is, and returns its URL. It
// can send what Go's server would not.
func startDiscriminatorWriting(t *testing.T, text string) string {
t.Helper()
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() {
_ = listener.Close()
})
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
_ = conn.SetDeadline(time.Now().Add(waitLimit))
_, _ = http.ReadRequest(bufio.NewReader(conn))
_, _ = io.WriteString(conn, text)
_ = conn.Close()
}
}()
return "http://" + listener.Addr().String()
}
// overOneMiB answers a question with a body over the most rproxy reads, and with
// disposition unless it is "".
func overOneMiB(disposition string) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
if disposition != "" {
w.Header().Set(dispositionHeader, disposition)
}
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write(make([]byte, 1<<20+1))
}
}
// switchAndHold answers a 101 and holds the connection open until rproxy closes
// it, or until long after any test has stopped waiting.
func switchAndHold(w http.ResponseWriter, _ *http.Request) {
conn, _, err := http.NewResponseController(w).Hijack()
if err != nil {
return
}
defer func() {
_ = conn.Close()
}()
_ = conn.SetDeadline(time.Now().Add(2 * waitLimit))
_, _ = io.WriteString(conn, "HTTP/1.1 101 Switching Protocols\r\n"+
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
_, _ = io.Copy(io.Discard, conn)
}
func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
t.Parallel()
for name, backend := range map[string]func(t *testing.T) string{
"a deny over 1 MiB": answeringWith(overOneMiB(dispositionDeny)),
"an allow over 1 MiB": answeringWith(overOneMiB(dispositionAllow)),
"no header over 1 MiB": answeringWith(overOneMiB("")),
"a status below 100": func(t *testing.T) string {
t.Helper()
return startDiscriminatorWriting(t, "HTTP/1.1 099 Odd\r\nContent-Length: 0\r\n\r\n")
},
"a 101 that holds its connection open": answeringWith(switchAndHold),
} {
for _, setting := range []string{dispositionAllow, dispositionDeny} {
t.Run(name+" with the default "+setting, func(t *testing.T) {
t.Parallel()
app := startApp(t, func(http.ResponseWriter, *http.Request) {
t.Error("the app was asked")
})
addr, out := startRProxy(t, backend(t), app.URL, map[string]string{
defaultDisposition: setting,
})
if got := get(t, addr, "/"); got.status != http.StatusBadGateway {
t.Errorf("answered %+v, want 502", got)
}
line := out.requestLine(t)
if line["status"] != float64(http.StatusBadGateway) ||
line["disposition"] != nil || line["default_disposition"] != nil {
t.Errorf("line %v, want status 502 and no disposition", line)
}
out.waitFor(t, "discriminator's answer is dropped")
})
}
}
}
func TestAnAnswerThatCannotBePassedOnGetsNoReport(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, overOneMiB(dispositionDeny))
addr, out := startRProxy(t, standIn.URL, nowhere, nil)
if got := get(t, addr, "/"); got.status != http.StatusBadGateway {
t.Errorf("answered %+v, want 502", got)
}
out.requestLine(t)
standIn.wantNoReport(t)
}
func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
t.Parallel()
for name, backend := range noAnswers() {
t.Run("fails open on "+name, func(t *testing.T) {
t.Parallel()
app := startApp(t, greet)
addr, out := startRProxy(t, backend(t), app.URL, map[string]string{
timeoutSetting: "200",
})
wantGreeting(t, get(t, addr, "/"))
line := out.requestLine(t)
if line["disposition"] != dispositionAllow ||
line["default_disposition"] != true {
t.Errorf("line %v, want allow by default", line)
}
})
t.Run("fails closed on "+name, func(t *testing.T) {
t.Parallel()
app := startApp(t, func(http.ResponseWriter, *http.Request) {
t.Error("the app was asked")
})
addr, out := startRProxy(t, backend(t), app.URL, map[string]string{
timeoutSetting: "200",
defaultDisposition: dispositionDeny,
})
got := get(t, addr, "/")
if got.status != http.StatusServiceUnavailable {
t.Errorf("answered %+v, want 503", got)
}
line := out.requestLine(t)
if line["disposition"] != dispositionDeny ||
line["default_disposition"] != true {
t.Errorf("line %v, want deny by default", line)
}
})
}
}
func TestAVisitorThatGoesAwayWhileDiscriminatorIsAskedGetsNoDefault(t *testing.T) {
t.Parallel()
for _, disposition := range []string{dispositionAllow, dispositionDeny} {
t.Run("with the default "+disposition, func(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, func(_ http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
})
app := startApp(t, func(http.ResponseWriter, *http.Request) {
t.Error("the app was asked")
})
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
timeoutSetting: "60000",
defaultDisposition: disposition,
})
conn := dial(t, addr)
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
standIn.question(t)
_ = conn.Close()
line := out.requestLine(t)
if line["status"] != 0.0 || line["disposition"] != nil ||
line["default_disposition"] != nil {
t.Errorf("line %v, want status 0 and no disposition", line)
}
if out.has("discriminator gave no answer") {
t.Error("rproxy warned that discriminator gave no answer")
}
standIn.wantNoReport(t)
})
}
}
func TestRProxyWarnsWhenTheDefaultDecides(t *testing.T) {
t.Parallel()
app := startApp(t, greet)
addr, out := startRProxy(t, nowhere, app.URL, nil)
wantGreeting(t, get(t, addr, "/"))
out.waitFor(t, "discriminator gave no answer")
}
func TestSendsNoReportWithoutAnAnswer(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, func(_ http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
})
app := startApp(t, greet)
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
timeoutSetting: "200",
})
wantGreeting(t, get(t, addr, "/"))
out.requestLine(t)
standIn.wantNoReport(t)
}
func TestAPathUnderOwnPrefixNeverReachesTheApp(t *testing.T) {
t.Parallel()
app := startApp(t, func(http.ResponseWriter, *http.Request) {
t.Error("the app was asked")
})
addr, _ := startRProxy(t, nowhere, app.URL, nil)
got := get(t, addr, "/_sww/metrics")
if got.status != http.StatusNotFound {
t.Errorf("answered %+v, want 404", got)
}
}
func TestHealthCheckIsAnsweredWhileDiscriminatorIsDown(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
app := startApp(t, greet)
addr, out := startRProxy(t, nowhere, app.URL, nil)
got := get(t, addr, discriminator.HealthPath)
if got.status != http.StatusOK || got.body != "ok\n" {
t.Errorf("answered %+v, want ok", got)
}
line := out.requestLine(t)
if line["path"] != discriminator.HealthPath || line["disposition"] != nil {
t.Errorf("line %v, want one with no disposition", line)
}
// With discriminator up too, it is not asked.
addr, _ = startRProxy(t, standIn.URL, app.URL, nil)
get(t, addr, discriminator.HealthPath)
if len(standIn.questions) != 0 {
t.Error("discriminator was asked about the health check")
}
}
func TestTheVisitorDoesNotWaitForTheReport(t *testing.T) {
t.Parallel()
release := make(chan struct{})
backend := startDiscriminatorAnsweringReports(t, deny,
func(w http.ResponseWriter, r *http.Request) {
select {
case <-release:
case <-r.Context().Done():
}
w.WriteHeader(http.StatusNoContent)
})
app := startApp(t, greet)
addr, _ := startRProxy(t, backend, app.URL, map[string]string{
timeoutSetting: "60000",
})
t.Cleanup(func() { close(release) })
// Go's server reads the next request on a connection only once the handler has
// returned, so the second answer comes only if the handler does not wait for
// the first report, which the stand-in leaves unanswered.
conn := dial(t, addr)
reader := bufio.NewReader(conn)
for range 2 {
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
res, err := http.ReadResponse(reader, nil)
if err != nil {
t.Fatalf("read the answer: %v", err)
}
_, _ = io.Copy(io.Discard, res.Body)
_ = res.Body.Close()
if res.StatusCode != http.StatusForbidden {
t.Errorf("status %d, want discriminator's 403", res.StatusCode)
}
}
}
func TestAReportDiscriminatorLeavesUnansweredIsDroppedAtTheTimeLimit(t *testing.T) {
t.Parallel()
release := make(chan struct{})
backend := startDiscriminatorAnsweringReports(t, allow,
func(w http.ResponseWriter, _ *http.Request) {
<-release
w.WriteHeader(http.StatusNoContent)
})
app := startApp(t, greet)
// The time limit is long enough for a loaded host to answer the question before
// it runs out.
addr, out := startRProxy(t, backend, app.URL, map[string]string{
timeoutSetting: "3000",
})
t.Cleanup(func() { close(release) })
wantGreeting(t, get(t, addr, "/"))
out.waitFor(t, "a report is dropped")
}
func TestAReportDiscriminatorRefusesIsDroppedWithAWarning(t *testing.T) {
t.Parallel()
backend := startDiscriminatorAnsweringReports(t, allow,
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
})
app := startApp(t, greet)
addr, out := startRProxy(t, backend, app.URL, nil)
wantGreeting(t, get(t, addr, "/"))
out.waitFor(t, "a report is dropped")
}
func TestAReportAnswered101IsDroppedAtOnce(t *testing.T) {
t.Parallel()
backend := startDiscriminatorAnsweringReports(t, allow, switchAndHold)
app := startApp(t, greet)
addr, out := startRProxy(t, backend, app.URL, map[string]string{
timeoutSetting: "60000",
})
wantGreeting(t, get(t, addr, "/"))
out.waitFor(t, "a report is dropped")
}
func TestAReportDueAfterWaitForReportsIsDropped(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
app := startApp(t, greet)
addr, out, server := startServer(t, standIn.URL, app.URL, nil)
server.WaitForReports()
wantGreeting(t, get(t, addr, "/"))
out.requestLine(t)
standIn.wantNoReport(t)
}
func TestReportGivesWhatHappened(t *testing.T) {
t.Parallel()
const body = "the visitor's body"
standIn := startDiscriminator(t, allow)
app := startApp(t, greet)
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
wantGreeting(t, visit(t, newRequest(t, http.MethodPost, addr, "/",
strings.NewReader(body))))
report := standIn.report(t)
if report.RequestID == "" || report.Status != http.StatusOK ||
report.RequestBytes != int64(len(body)) ||
report.ResponseBytes != int64(len(greeting)) ||
report.DurationTotal <= 0 || report.DurationUpstreamTotal <= 0 ||
report.DurationUpstreamTotal > report.DurationTotal {
t.Errorf("report %+v, of %d bytes up and %d back", report, len(body), len(greeting))
}
line := out.requestLine(t)
if line["request_id"] != report.RequestID {
t.Errorf("line %v, report %+v, want the same request id", line, report)
}
}
func TestWaitForReportsWaitsForAReportBeingSent(t *testing.T) {
t.Parallel()
reportGotThere := make(chan struct{})
release := make(chan struct{})
backend := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(discriminator.ClientHeader) != "" {
allow(w, r)
return
}
close(reportGotThere)
select {
case <-release:
case <-r.Context().Done():
}
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(backend.Close)
app := startApp(t, greet)
addr, _, server := startServer(t, backend.URL, app.URL, map[string]string{
timeoutSetting: "5000",
})
wantGreeting(t, get(t, addr, "/"))
select {
case <-reportGotThere:
case <-time.After(waitLimit):
t.Fatal("no report")
}
waited := make(chan struct{})
go func() {
server.WaitForReports()
close(waited)
}()
select {
case <-waited:
t.Error("it did not wait for the report")
case <-time.After(10 * pollInterval):
}
close(release)
select {
case <-waited:
case <-time.After(waitLimit):
t.Error("it still waits, the report having been answered")
}
}
func TestReportOfAWebSocketCountsWhatItCarried(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
app := startApp(t, echoAfterUpgrade)
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxRequestTime: "2000",
maxResponseTime: "2000",
})
conn := dial(t, addr)
send(t, conn, "GET /socket HTTP/1.1\r\nHost: app\r\n"+
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
reader := bufio.NewReader(conn)
res, err := http.ReadResponse(reader, nil)
if err != nil {
t.Fatalf("read the answer to the upgrade: %v", err)
}
_ = res.Body.Close()
if res.StatusCode != http.StatusSwitchingProtocols {
t.Fatalf("status %d, want %d", res.StatusCode, http.StatusSwitchingProtocols)
}
// Past both time limits, the connection still carries what is sent.
time.Sleep(2100 * time.Millisecond)
const message = "still here\n"
send(t, conn, message)
echoed, err := reader.ReadString('\n')
if err != nil || echoed != message {
t.Errorf("echo %q (%v), want %q", echoed, err, message)
}
_ = conn.Close()
report := standIn.report(t)
if report.Status != http.StatusSwitchingProtocols ||
report.RequestBytes != int64(len(message)) ||
report.ResponseBytes != int64(len(message)) {
t.Errorf("report %+v, of %d bytes each way", report, len(message))
}
if line := out.requestLine(t); line["status"] != float64(res.StatusCode) {
t.Errorf("line %v, want status 101", line)
}
}
func TestAWebSocketPassesOnThatTheVisitorStoppedSending(t *testing.T) {
t.Parallel()
const goodbye = "sent after the visitor stopped sending\n"
standIn := startDiscriminator(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
conn, buffered, err := http.NewResponseController(w).Hijack()
if err != nil {
return
}
defer func() {
_ = conn.Close()
}()
_, _ = buffered.WriteString("HTTP/1.1 101 Switching Protocols\r\n" +
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
_ = buffered.Flush()
_, _ = io.Copy(io.Discard, buffered)
_, _ = buffered.WriteString(goodbye)
_ = buffered.Flush()
})
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
conn := dial(t, addr)
send(t, conn, "GET /socket HTTP/1.1\r\nHost: app\r\n"+
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
reader := bufio.NewReader(conn)
res, err := http.ReadResponse(reader, nil)
if err != nil {
t.Fatalf("read the answer to the upgrade: %v", err)
}
_ = res.Body.Close()
tcpConn, ok := conn.(*net.TCPConn)
if !ok {
t.Fatalf("the connection is a %T", conn)
}
err = tcpConn.CloseWrite()
if err != nil {
t.Fatalf("stop sending: %v", err)
}
text, err := io.ReadAll(reader)
if err != nil || string(text) != goodbye {
t.Errorf("read %q (%v), want %q", text, err, goodbye)
}
}
// This test does not run in parallel. Go closes a connection nothing refers to at
// its next garbage collection, which other tests' allocations would soon start, and
// that would hide a connection left open.
//
//nolint:paralleltest // see above
func TestASwitchNotPassedOnClosesTheAppsConnection(t *testing.T) {
closed := make(chan error, 1)
standIn := startDiscriminator(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
conn, buffered, err := http.NewResponseController(w).Hijack()
if err != nil {
closed <- err
return
}
defer func() {
_ = conn.Close()
}()
// Not the protocol the visitor asked for, so ReverseProxy refuses the switch.
_, _ = buffered.WriteString("HTTP/1.1 101 Switching Protocols\r\n" +
"Connection: Upgrade\r\nUpgrade: another\r\n\r\n")
_ = buffered.Flush()
_ = conn.SetReadDeadline(time.Now().Add(waitLimit))
_, err = buffered.ReadByte()
closed <- err
})
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
conn := dial(t, addr)
send(t, conn, "GET /socket HTTP/1.1\r\nHost: app\r\n"+
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
if status := readStatus(t, conn); status != http.StatusBadGateway {
t.Errorf("status %d, want 502", status)
}
err := <-closed
if !errors.Is(err, io.EOF) {
t.Errorf("the app's read gave %v, want its connection closed", err)
}
}
// echoAfterUpgrade is an app that switches protocols on request, and then sends
// back each line it receives.
func echoAfterUpgrade(w http.ResponseWriter, _ *http.Request) {
conn, buffered, err := http.NewResponseController(w).Hijack()
if err != nil {
return
}
defer func() {
_ = conn.Close()
}()
_, _ = buffered.WriteString("HTTP/1.1 101 Switching Protocols\r\n" +
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
_ = buffered.Flush()
for {
line, err := buffered.ReadString('\n')
if err != nil {
return
}
_, _ = buffered.WriteString(line)
_ = buffered.Flush()
}
}
func TestEachRequestGetsAFreshID(t *testing.T) {
t.Parallel()
const fromTraefik = "an-id-from-traefik"
standIn := startDiscriminator(t, allow)
app := startApp(t, greet)
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
trustedProxies: localhost + "/32",
})
ids := make([]string, 0, 2)
for range 2 {
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
req.Header.Set(requestIDHeader, fromTraefik)
wantGreeting(t, visit(t, req))
ids = append(ids, standIn.question(t).header.Get(requestIDHeader))
}
if slices.Contains(ids, fromTraefik) || ids[0] == "" || ids[0] == ids[1] {
t.Errorf("ids %v, want two fresh ones", ids)
}
}
func TestLogLineGivesTheRequestAsSent(t *testing.T) {
t.Parallel()
const (
path = "/search%2Fall"
query = "q=secret&token=%zz"
body = "the visitor's body"
)
standIn := startDiscriminator(t, allow)
app := startApp(t, greet)
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
req := newRequest(t, http.MethodPost, addr, path+"?"+query, strings.NewReader(body))
req.Host = site
wantGreeting(t, visit(t, req))
line := out.requestLine(t)
asked := standIn.question(t)
for name, want := range map[string]any{
"type": "request",
"request_id": asked.header.Get(requestIDHeader),
"client_ip": localhost,
"host": site,
"method": http.MethodPost,
"path": path,
"query": query,
"status": float64(http.StatusOK),
"request_bytes": float64(len(body)),
"response_bytes": float64(len(greeting)),
"disposition": dispositionAllow,
} {
if line[name] != want {
t.Errorf("%s is %v, want %v", name, line[name], want)
}
}
if line["default_disposition"] != nil {
t.Errorf("default_disposition is %v, want it left out", line["default_disposition"])
}
for _, name := range []string{"duration_request", "duration_response"} {
duration, ok := line[name].(float64)
if !ok || duration <= 0 {
t.Errorf("%s is %v, want a time", name, line[name])
}
}
text, _ := line["time"].(string)
_, err := time.Parse(time.RFC3339, text)
if err != nil {
t.Errorf("time: %v", err)
}
}