Ctrl K
ComunidadGuías…

engine/revproxy_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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)
		}
	}
}