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>
This commit is contained in:
368
tide/proxy_test.go
Normal file
368
tide/proxy_test.go
Normal file
@@ -0,0 +1,368 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user