Files
summercms/tide/proxy_test.go
Jakub Zych bb6a5a91c9 feat(02-02): capture named sessions through a loopback proxy
Record Nuxt/MCP traffic as ordered flows via parity:proxy, pin
loopback upstream, and refuse oversized or credential-shaped fixtures.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 12:27:57 +02:00

369 lines
9.9 KiB
Go

package tide
import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestProxyNamedSessionsOrderedAndIsolated(t *testing.T) {
var seenHost []string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seenHost = append(seenHost, r.Host)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"path":"` + r.URL.Path + `"}`))
}))
t.Cleanup(upstream.Close)
fixtures := t.TempDir()
proxy := newTestProxy(t, upstream.URL, fixtures, testRulesYAML())
srv := httptest.NewServer(proxy.Handler())
t.Cleanup(srv.Close)
doProxy(t, srv.URL, "alpha", "/one", "")
doProxy(t, srv.URL, "alpha", "/two", "")
doProxy(t, srv.URL, "beta", "/other", "")
if err := proxy.Flush(); err != nil {
t.Fatal(err)
}
alpha, err := LoadFlow(filepath.Join(fixtures, "nuxt", "alpha.yaml"))
if err != nil {
t.Fatal(err)
}
if len(alpha.Steps) != 2 {
t.Fatalf("alpha steps: %d", len(alpha.Steps))
}
if alpha.Steps[0].Request.Path != "/one" || alpha.Steps[1].Request.Path != "/two" {
t.Fatalf("alpha order: %+v", alpha.Steps)
}
beta, err := LoadFlow(filepath.Join(fixtures, "nuxt", "beta.yaml"))
if err != nil {
t.Fatal(err)
}
if len(beta.Steps) != 1 || beta.Steps[0].Request.Path != "/other" {
t.Fatalf("beta interleaved: %+v", beta.Steps)
}
req, err := http.NewRequest(http.MethodGet, srv.URL+"/evil", nil)
if err != nil {
t.Fatal(err)
}
req.Host = "evil.example"
req.Header.Set(SessionHeader, "gamma")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if len(seenHost) < 4 {
t.Fatalf("upstream hits: %d", len(seenHost))
}
wantHost := strings.TrimPrefix(upstream.URL, "http://")
if seenHost[len(seenHost)-1] != wantHost {
t.Fatalf("client Host leaked: last=%s want=%s all=%v", seenHost[len(seenHost)-1], wantHost, seenHost)
}
}
func TestProxyForwardsCookieRedirectAndBody(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/go" {
w.Header().Set("Set-Cookie", "sid=abc; Path=/")
w.Header().Set("Location", "/landed")
w.WriteHeader(http.StatusFound)
_, _ = w.Write([]byte("redirect-body"))
return
}
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("cookie=" + r.Header.Get("Cookie")))
}))
t.Cleanup(upstream.Close)
fixtures := t.TempDir()
proxy := newTestProxy(t, upstream.URL, fixtures, testRulesYAML())
srv := httptest.NewServer(proxy.Handler())
t.Cleanup(srv.Close)
client := &http.Client{
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
req, err := http.NewRequest(http.MethodGet, srv.URL+"/go", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set(SessionHeader, "redir")
req.Header.Set("Cookie", "keep=1")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Fatalf("status %d", resp.StatusCode)
}
if loc := resp.Header.Get("Location"); loc != "/landed" {
t.Fatalf("location %q", loc)
}
if !strings.Contains(resp.Header.Get("Set-Cookie"), "sid=abc") {
t.Fatalf("set-cookie %q", resp.Header.Get("Set-Cookie"))
}
if string(body) != "redirect-body" {
t.Fatalf("body %q", body)
}
req2, err := http.NewRequest(http.MethodGet, srv.URL+"/echo", nil)
if err != nil {
t.Fatal(err)
}
req2.Header.Set(SessionHeader, "redir")
req2.Header.Set("Cookie", "keep=1")
resp2, err := client.Do(req2)
if err != nil {
t.Fatal(err)
}
got, _ := io.ReadAll(resp2.Body)
resp2.Body.Close()
if string(got) != "cookie=keep=1" {
t.Fatalf("cookie not forwarded: %q", got)
}
}
func TestProxyRejectsNonLoopbackOverflowAndCredentials(t *testing.T) {
if _, err := NewProxy(ProxyConfig{
Listen: "0.0.0.0:8422",
Upstream: "http://127.0.0.1:8423",
Fixtures: t.TempDir(),
Rules: mustParseRules(t, testRulesYAML()),
}); err == nil || !strings.Contains(err.Error(), "loopback") {
t.Fatalf("non-loopback listen: %v", err)
}
if _, err := NewProxy(ProxyConfig{
Listen: "127.0.0.1:8422",
Upstream: "http://example.com",
Fixtures: t.TempDir(),
Rules: mustParseRules(t, testRulesYAML()),
}); err == nil || !strings.Contains(err.Error(), "loopback") {
t.Fatalf("non-loopback upstream: %v", err)
}
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("hello-world"))
}))
t.Cleanup(upstream.Close)
fixtures := t.TempDir()
cfg := ProxyConfig{
Listen: "127.0.0.1:0",
Upstream: upstream.URL,
Fixtures: fixtures,
Rules: mustParseRules(t, testRulesYAML()),
MaxBody: 4,
}
proxy, err := NewProxy(cfg)
if err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(proxy.Handler())
t.Cleanup(srv.Close)
req, err := http.NewRequest(http.MethodGet, srv.URL+"/sample", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set(SessionHeader, "overflow")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusRequestEntityTooLarge && resp.StatusCode != http.StatusBadGateway && resp.StatusCode != http.StatusInternalServerError {
if resp.StatusCode == http.StatusOK {
t.Fatal("overflow recorded as success")
}
}
overflowPath := filepath.Join(fixtures, "nuxt", "overflow.yaml")
if _, statErr := os.Stat(overflowPath); !os.IsNotExist(statErr) {
t.Fatalf("partial fixture committed: %v", statErr)
}
bigUp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.abcabcabcabcabcabcab"}`))
}))
t.Cleanup(bigUp.Close)
secretFix := t.TempDir()
secretProxy, err := NewProxy(ProxyConfig{
Listen: "127.0.0.1:0",
Upstream: bigUp.URL,
Fixtures: secretFix,
Rules: mustParseRules(t, testRulesYAML()),
})
if err != nil {
t.Fatal(err)
}
secretSrv := httptest.NewServer(secretProxy.Handler())
t.Cleanup(secretSrv.Close)
sreq, err := http.NewRequest(http.MethodGet, secretSrv.URL+"/sample", nil)
if err != nil {
t.Fatal(err)
}
sreq.Header.Set(SessionHeader, "secret")
sresp, err := http.DefaultClient.Do(sreq)
if err != nil {
t.Fatal(err)
}
sresp.Body.Close()
if _, statErr := os.Stat(filepath.Join(secretFix, "nuxt", "secret.yaml")); !os.IsNotExist(statErr) {
t.Fatalf("credential fixture was committed: %v", statErr)
}
reqBad, err := http.NewRequest(http.MethodGet, srv.URL+"/sample", nil)
if err != nil {
t.Fatal(err)
}
reqBad.Header.Set(SessionHeader, "alice/../bob")
bresp, err := http.DefaultClient.Do(reqBad)
if err != nil {
t.Fatal(err)
}
bresp.Body.Close()
if bresp.StatusCode == http.StatusOK {
t.Fatal("path-separator session must fail")
}
inside := filepath.Join(fixtures, "vars.yaml")
if _, err := NewProxy(ProxyConfig{
Listen: "127.0.0.1:0",
Upstream: upstream.URL,
Fixtures: fixtures,
VarsPath: inside,
Rules: mustParseRules(t, testRulesYAML()),
}); err == nil || !strings.Contains(err.Error(), "outside") {
t.Fatalf("vars inside fixtures: %v", err)
}
outside := filepath.Join(t.TempDir(), "vars.yaml")
p, err := NewProxy(ProxyConfig{
Listen: "127.0.0.1:0",
Upstream: upstream.URL,
Fixtures: fixtures,
VarsPath: outside,
Rules: mustParseRules(t, testRulesYAML()),
})
if err != nil {
t.Fatal(err)
}
_ = p
st, err := os.Stat(outside)
if err != nil {
t.Fatal(err)
}
if st.Mode().Perm() != 0o600 {
t.Fatalf("vars mode %o", st.Mode().Perm())
}
}
func TestProxyDuplicateSessionName(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}))
t.Cleanup(upstream.Close)
fixtures := t.TempDir()
if err := os.MkdirAll(filepath.Join(fixtures, "nuxt"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(fixtures, "nuxt", "taken.yaml"), []byte("version: 1\n"), 0o644); err != nil {
t.Fatal(err)
}
proxy := newTestProxy(t, upstream.URL, fixtures, testRulesYAML())
srv := httptest.NewServer(proxy.Handler())
t.Cleanup(srv.Close)
req, err := http.NewRequest(http.MethodGet, srv.URL+"/sample", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set(SessionHeader, "taken")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
t.Fatal("duplicate session must fail")
}
}
func newTestProxy(t *testing.T, upstream, fixtures, rulesYAML string) *Proxy {
t.Helper()
p, err := NewProxy(ProxyConfig{
Listen: "127.0.0.1:0",
Upstream: upstream,
Fixtures: fixtures,
Rules: mustParseRules(t, rulesYAML),
})
if err != nil {
t.Fatal(err)
}
return p
}
func mustParseRules(t *testing.T, raw string) Rules {
t.Helper()
rules, err := ParseRules([]byte(raw))
if err != nil {
t.Fatal(err)
}
return rules
}
func testRulesYAML() string {
return "" +
"client: nuxt\n" +
"keep_request_headers:\n" +
" - Cookie\n" +
" - Content-Type\n" +
"keep_response_headers:\n" +
" - Content-Type\n" +
" - Location\n" +
" - Set-Cookie\n" +
"routes:\n" +
" - method: GET\n" +
" path: /sample\n" +
" - method: GET\n" +
" path: /*\n"
}
func doProxy(t *testing.T, base, session, path, body string) {
t.Helper()
var rdr io.Reader
if body != "" {
rdr = strings.NewReader(body)
}
req, err := http.NewRequest(http.MethodGet, base+path, rdr)
if err != nil {
t.Fatal(err)
}
req.Header.Set(SessionHeader, session)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("%s %s: %d %s", session, path, resp.StatusCode, b)
}
}