engine/revproxy_test.go
Rama main · réplica de lectura del repositorio original.
package engine
import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
// newTestProxyConfig points pathPrefix at the given backend URL
func newTestProxyConfig(t *testing.T, pathPrefix, endpoint string) *ReverseProxyConfig {
t.Helper()
u, err := url.Parse(endpoint)
if err != nil {
t.Fatal(err)
}
rc := NewReverseProxyConfig()
rc.Add(&ReverseProxy{PathPrefix: pathPrefix, Endpoint: *u})
return rc
}
// A redirect from the backend must reach the client instead of being followed
// by the proxy itself.
func TestReverseProxyDoesNotFollowRedirects(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.URL.Path == "/redir" {
http.Redirect(w, req, "/landed", http.StatusFound)
return
}
io.WriteString(w, "landed")
}))
defer backend.Close()
rp := newTestProxyConfig(t, "/api", backend.URL).FindMatchingReverseProxy("/api/redir")
if rp == nil {
t.Fatal("expected a matching reverse proxy")
}
rec := httptest.NewRecorder()
rp.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/redir", nil))
if rec.Code != http.StatusFound {
t.Errorf("status = %d, want %d", rec.Code, http.StatusFound)
}
if loc := rec.Header().Get("Location"); loc != "/landed" {
t.Errorf("Location = %q, want %q", loc, "/landed")
}
}
// The backend must be able to see the original client and scheme.
func TestReverseProxySetsForwardedHeaders(t *testing.T) {
var gotFor, gotProto, gotHost, gotPath, gotHostHeader string
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
gotFor = req.Header.Get("X-Forwarded-For")
gotProto = req.Header.Get("X-Forwarded-Proto")
gotHost = req.Header.Get("X-Forwarded-Host")
gotHostHeader = req.Host
gotPath = req.URL.Path
}))
defer backend.Close()
rp := newTestProxyConfig(t, "/api", backend.URL).FindMatchingReverseProxy("/api/whoami")
if rp == nil {
t.Fatal("expected a matching reverse proxy")
}
req := httptest.NewRequest(http.MethodGet, "/api/whoami", nil)
req.Host = "example.com"
req.RemoteAddr = "203.0.113.7:1234"
rp.ServeHTTP(httptest.NewRecorder(), req)
if gotFor != "203.0.113.7" {
t.Errorf("X-Forwarded-For = %q, want %q", gotFor, "203.0.113.7")
}
if gotProto != "http" {
t.Errorf("X-Forwarded-Proto = %q, want %q", gotProto, "http")
}
if gotHost != "example.com" {
t.Errorf("X-Forwarded-Host = %q, want %q", gotHost, "example.com")
}
// The client's Host is forwarded, so the backend builds correct absolute URLs
if gotHostHeader != "example.com" {
t.Errorf("Host = %q, want %q", gotHostHeader, "example.com")
}
// The path prefix is stripped
if gotPath != "/whoami" {
t.Errorf("path = %q, want %q", gotPath, "/whoami")
}
}
// Requesting exactly the prefix must produce "/" upstream, not an empty path.
func TestReverseProxyPrefixOnlyPath(t *testing.T) {
var gotPath string
backend := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, req *http.Request) {
gotPath = req.URL.Path
}))
defer backend.Close()
rp := newTestProxyConfig(t, "/api", backend.URL).FindMatchingReverseProxy("/api")
if rp == nil {
t.Fatal("expected a matching reverse proxy")
}
rp.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api", nil))
if gotPath != "/" {
t.Errorf("path = %q, want %q", gotPath, "/")
}
}
// A base path on the endpoint is kept in front of the stripped path.
func TestReverseProxyEndpointBasePath(t *testing.T) {
var gotPath string
backend := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, req *http.Request) {
gotPath = req.URL.Path
}))
defer backend.Close()
rp := newTestProxyConfig(t, "/api", backend.URL+"/v2").FindMatchingReverseProxy("/api/things")
if rp == nil {
t.Fatal("expected a matching reverse proxy")
}
rp.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/things", nil))
if gotPath != "/v2/things" {
t.Errorf("path = %q, want %q", gotPath, "/v2/things")
}
}
// An unreachable backend gives 502, not a partial 200.
func TestReverseProxyBadGateway(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
backendURL := backend.URL
backend.Close() // nothing is listening any more
rp := newTestProxyConfig(t, "/api", backendURL).FindMatchingReverseProxy("/api/gone")
if rp == nil {
t.Fatal("expected a matching reverse proxy")
}
rec := httptest.NewRecorder()
rp.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/gone", nil))
if rec.Code != http.StatusBadGateway {
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadGateway)
}
if !strings.Contains(rec.Body.String(), "reverse proxy error") {
t.Errorf("body = %q, want it to mention a reverse proxy error", rec.Body.String())
}
}
// Multi-valued headers must keep all of their values.
func TestReverseProxyMultiValueHeaders(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Add("Set-Cookie", "a=1")
w.Header().Add("Set-Cookie", "b=2")
w.Header().Add("Vary", "Accept")
w.Header().Add("Vary", "Origin")
io.WriteString(w, "ok")
}))
defer backend.Close()
rp := newTestProxyConfig(t, "/api", backend.URL).FindMatchingReverseProxy("/api/x")
if rp == nil {
t.Fatal("expected a matching reverse proxy")
}
rec := httptest.NewRecorder()
rp.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/x", nil))
if got := rec.Result().Header.Values("Set-Cookie"); len(got) != 2 {
t.Errorf("Set-Cookie = %v, want 2 values", got)
}
if got := rec.Result().Header.Values("Vary"); len(got) != 2 {
t.Errorf("Vary = %v, want 2 values", got)
}
}
// An upstream that closes right after the headers must give 502, not an empty 200.
func TestReverseProxyUpstreamDiesAfterHeaders(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Length", "512")
w.WriteHeader(http.StatusOK)
// Drop the connection without sending the promised body
conn, _, err := http.NewResponseController(w).Hijack()
if err == nil {
conn.Close()
}
}))
defer backend.Close()
rp := newTestProxyConfig(t, "/api", backend.URL).FindMatchingReverseProxy("/api/x")
if rp == nil {
t.Fatal("expected a matching reverse proxy")
}
rec := httptest.NewRecorder()
rp.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/x", nil))
if rec.Code != http.StatusBadGateway {
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadGateway)
}
}
// An event stream must not be peeked at, so that headers reach the client
// before the first event is produced.
func TestReverseProxyEventStreamNotPeeked(t *testing.T) {
release := make(chan struct{})
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
http.NewResponseController(w).Flush()
<-release // no body until the test says so
io.WriteString(w, "data: hello\n\n")
}))
defer backend.Close()
rp := newTestProxyConfig(t, "/api", backend.URL).FindMatchingReverseProxy("/api/stream")
if rp == nil {
t.Fatal("expected a matching reverse proxy")
}
front := httptest.NewServer(http.HandlerFunc(rp.ServeHTTP))
defer front.Close()
// http.Get returns as soon as the headers arrive, so this blocks if the
// proxy waits for the first body byte
type result struct {
resp *http.Response
err error
}
ch := make(chan result, 1)
go func() {
resp, err := http.Get(front.URL + "/api/stream")
ch <- result{resp, err}
}()
select {
case <-time.After(3 * time.Second):
close(release)
t.Fatal("proxying an event stream blocked waiting for the first byte")
case got := <-ch:
close(release)
if got.err != nil {
t.Fatal(got.err)
}
defer got.resp.Body.Close()
if got.resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want %d", got.resp.StatusCode, http.StatusOK)
}
body, _ := io.ReadAll(got.resp.Body)
if !strings.Contains(string(body), "data: hello") {
t.Errorf("body = %q, want it to contain the event", string(body))
}
}
}
// A response of unknown length may be a stream or a long poll, so it must not
// be peeked at either, even when it is not an event stream.
func TestReverseProxyChunkedResponseNotPeeked(t *testing.T) {
release := make(chan struct{})
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
// No Content-Length, so the response is chunked
w.Header().Set(contentType, "application/x-ndjson")
w.WriteHeader(http.StatusOK)
http.NewResponseController(w).Flush()
<-release // no body until the test says so
io.WriteString(w, "{\"hello\":1}\n")
}))
defer backend.Close()
rp := newTestProxyConfig(t, "/api", backend.URL).FindMatchingReverseProxy("/api/poll")
if rp == nil {
t.Fatal("expected a matching reverse proxy")
}
front := httptest.NewServer(http.HandlerFunc(rp.ServeHTTP))
defer front.Close()
type result struct {
resp *http.Response
err error
}
ch := make(chan result, 1)
go func() {
resp, err := http.Get(front.URL + "/api/poll")
ch <- result{resp, err}
}()
select {
case <-time.After(3 * time.Second):
close(release)
t.Fatal("proxying a long poll blocked waiting for the first byte")
case got := <-ch:
close(release)
if got.err != nil {
t.Fatal(got.err)
}
defer got.resp.Body.Close()
if got.resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want %d", got.resp.StatusCode, http.StatusOK)
}
body, _ := io.ReadAll(got.resp.Body)
if !strings.Contains(string(body), "hello") {
t.Errorf("body = %q, want it to contain the response", string(body))
}
}
}
// The query string survives proxying.
func TestReverseProxyKeepsQueryString(t *testing.T) {
var gotQuery string
backend := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, req *http.Request) {
gotQuery = req.URL.RawQuery
}))
defer backend.Close()
rp := newTestProxyConfig(t, "/api", backend.URL).FindMatchingReverseProxy("/api/search")
if rp == nil {
t.Fatal("expected a matching reverse proxy")
}
rp.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/search?q=hi&n=2", nil))
if gotQuery != "q=hi&n=2" {
t.Errorf("query = %q, want %q", gotQuery, "q=hi&n=2")
}
}
// The longest matching prefix wins.
func TestReverseProxyLongestPrefixWins(t *testing.T) {
rc := NewReverseProxyConfig()
for _, pair := range [][2]string{{"/api", "http://one.invalid"}, {"/api/inner", "http://two.invalid"}} {
u, err := url.Parse(pair[1])
if err != nil {
t.Fatal(err)
}
rc.Add(&ReverseProxy{PathPrefix: pair[0], Endpoint: *u})
}
rp := rc.FindMatchingReverseProxy("/api/inner/x")
if rp == nil {
t.Fatal("expected a matching reverse proxy")
}
if rp.PathPrefix != "/api/inner" {
t.Errorf("PathPrefix = %q, want %q", rp.PathPrefix, "/api/inner")
}
}
// A prefix only matches whole path segments, and a root prefix is the fallback.
func TestReverseProxyPrefixSegmentBoundary(t *testing.T) {
rc := NewReverseProxyConfig()
for _, pair := range [][2]string{{"/", "http://root.invalid"}, {"/api", "http://api.invalid"}, {"/files/", "http://files.invalid"}} {
rp, err := NewReverseProxy(pair[0], pair[1])
if err != nil {
t.Fatal(err)
}
rc.Add(rp)
}
for path, want := range map[string]string{
"/api": "/api",
"/api/": "/api",
"/api/x": "/api",
"/apiary": "/",
"/files/a.txt": "/files/",
"/files": "/",
"/": "/",
} {
rp := rc.FindMatchingReverseProxy(path)
if rp == nil {
t.Errorf("%s: expected a match", path)
continue
}
if rp.PathPrefix != want {
t.Errorf("%s: PathPrefix = %q, want %q", path, rp.PathPrefix, want)
}
}
rc = newTestProxyConfig(t, "/api", "http://api.invalid")
if rp := rc.FindMatchingReverseProxy("/apiary"); rp != nil {
t.Errorf("/apiary: expected no match, got %q", rp.PathPrefix)
}
}
func TestParseReverseProxyFlag(t *testing.T) {
for value, want := range map[string][2]string{
"3000": {"/", "http://localhost:3000"},
":3000": {"/", "http://localhost:3000"},
"example.com:8080": {"/", "http://example.com:8080"},
"https://example.com/v2": {"/", "https://example.com/v2"},
"/api=3000": {"/api", "http://localhost:3000"},
"/api=http://127.0.0.1:9000": {"/api", "http://127.0.0.1:9000"},
"/q=http://h:1/?a=b": {"/q", "http://h:1/?a=b"},
} {
rp, err := ParseReverseProxyFlag(value)
if err != nil {
t.Errorf("%s: %v", value, err)
continue
}
if rp.PathPrefix != want[0] || rp.Endpoint.String() != want[1] {
t.Errorf("%s: got %s -> %s, want %s -> %s", value, rp.PathPrefix, rp.Endpoint.String(), want[0], want[1])
}
}
for _, value := range []string{"/api", "/api=", "", "http://"} {
if _, err := ParseReverseProxyFlag(value); err == nil {
t.Errorf("%q: expected an error", value)
}
}
}