hatch-surf/internal/handler/replay_test.go
SoftwareEngineer 4256d34c27 feat: add one-click replay for captured requests
- Add POST /e/{endpoint}/requests/{id}/replay endpoint
- Replay re-sends captured method, path, headers, query, body to target URL
- Response includes target status code, headers, body (truncated 64KB)
- SSRF guard: denies replay to private/loopback by default
- Opt-in env var HATCH_ALLOW_PRIVATE_REPLAY=true for local dev
- Server-rendered inspect page at GET /e/{endpoint} with replay button UI
- SSE live stream for new requests (EventSource)
- Mock configuration at PUT /e/{endpoint}/mock
- Store: GetRequest, GetMock, SetMock methods added
- Chi router migration from stdlib ServeMux
- 26 passing tests including replay unit tests and E2E capture-to-replay

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-22 16:58:42 +02:00

237 lines
6.9 KiB
Go

package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/elfoundation/hatch/internal/store"
)
func TestReplayMissingTargetURL(t *testing.T) {
repo := newFakeRepo()
repo.CreateEndpoint(nil, "ep")
repo.AppendRequest(nil, "ep", &store.Request{Method: "POST", Path: "/webhook", Headers: "{}"})
reqID := repo.requests[0].ID
r := testRouter(repo)
body := `{}`
req := httptest.NewRequest(http.MethodPost, "/e/ep/requests/"+reqID+"/replay", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if !strings.Contains(resp["error"], "target_url") {
t.Errorf("expected target_url error, got %v", resp)
}
}
func TestReplayInvalidScheme(t *testing.T) {
repo := newFakeRepo()
repo.CreateEndpoint(nil, "ep")
repo.AppendRequest(nil, "ep", &store.Request{Method: "GET", Path: "/", Headers: "{}"})
reqID := repo.requests[0].ID
r := testRouter(repo)
body := `{"target_url": "ftp://bad.scheme/"}`
req := httptest.NewRequest(http.MethodPost, "/e/ep/requests/"+reqID+"/replay", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestReplaySSRFBlocksLocalhost(t *testing.T) {
repo := newFakeRepo()
repo.CreateEndpoint(nil, "ep")
repo.AppendRequest(nil, "ep", &store.Request{Method: "GET", Path: "/", Headers: "{}"})
reqID := repo.requests[0].ID
r := testRouter(repo)
body := `{"target_url": "http://localhost:8080/"}`
req := httptest.NewRequest(http.MethodPost, "/e/ep/requests/"+reqID+"/replay", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for localhost, got %d", w.Code)
}
}
func TestReplaySSRFBlocksPrivate(t *testing.T) {
repo := newFakeRepo()
repo.CreateEndpoint(nil, "ep")
repo.AppendRequest(nil, "ep", &store.Request{Method: "GET", Path: "/", Headers: "{}"})
reqID := repo.requests[0].ID
r := testRouter(repo)
for _, addr := range []string{
"http://10.0.0.1:80/",
"http://172.16.0.1:80/",
"http://192.168.1.1:80/",
"http://127.0.0.1:80/",
"http://[::1]:80/",
"http://0.0.0.0:80/",
} {
body := `{"target_url": "` + addr + `"}`
req := httptest.NewRequest(http.MethodPost, "/e/ep/requests/"+reqID+"/replay", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403 for %s, got %d", addr, w.Code)
}
}
}
func TestReplayNotFound(t *testing.T) {
repo := newFakeRepo()
r := testRouter(repo)
body := `{"target_url": "https://example.com/"}`
req := httptest.NewRequest(http.MethodPost, "/e/ep/requests/nonexistent/replay", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestReplaySuccess(t *testing.T) {
// Start a local sink server that echoes back what it receives.
sink := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Sink", "yes")
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"received": true}`))
}))
defer sink.Close()
repo := newFakeRepo()
repo.CreateEndpoint(nil, "ep")
repo.AppendRequest(nil, "ep", &store.Request{
Method: "POST",
Path: "/webhook",
Headers: `{"Content-Type":"application/json","X-Custom":"val"}`,
Query: "foo=bar",
Body: []byte(`{"msg":"hello"}`),
})
reqID := repo.requests[0].ID
// Set env to allow private replay since httptest server is on loopback.
t.Setenv("HATCH_ALLOW_PRIVATE_REPLAY", "true")
r := testRouter(repo)
body := `{"target_url": "` + sink.URL + `"}`
req := httptest.NewRequest(http.MethodPost, "/e/ep/requests/"+reqID+"/replay", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp replayResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Status != 201 {
t.Errorf("expected status 201, got %d", resp.Status)
}
if resp.Headers["X-Sink"] != "yes" {
t.Errorf("expected X-Sink header, got %v", resp.Headers)
}
if resp.Body != `{"received": true}` {
t.Errorf("expected body, got %q", resp.Body)
}
}
func TestReplayE2ECaptureThenReplay(t *testing.T) {
// E2E: capture a request, then replay it to a sink, verify the sink received it.
// Step 1: Start a sink.
sink := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"method": r.Method,
"path": r.URL.Path,
"query": r.URL.RawQuery,
"body": "received",
})
}))
defer sink.Close()
// Step 2: Capture a request.
repo := newFakeRepo()
rt := testRouter(repo)
captureReq := httptest.NewRequest("POST", "/e2e-test", strings.NewReader(`{"order":"1"}`))
captureReq.Header.Set("X-Test", "hello")
captureW := httptest.NewRecorder()
rt.ServeHTTP(captureW, captureReq)
if len(repo.requests) != 1 {
t.Fatalf("expected 1 captured request, got %d", len(repo.requests))
}
captured := repo.requests[0]
if captured.Method != "POST" {
t.Errorf("captured method: %s", captured.Method)
}
// Step 3: Replay it to the sink.
t.Setenv("HATCH_ALLOW_PRIVATE_REPLAY", "true")
replayBody := `{"target_url": "` + sink.URL + `"}`
replayReq := httptest.NewRequest("POST", "/e/e2e-test/requests/"+captured.ID+"/replay", strings.NewReader(replayBody))
replayReq.Header.Set("Content-Type", "application/json")
replayW := httptest.NewRecorder()
rt.ServeHTTP(replayW, replayReq)
if replayW.Code != http.StatusOK {
t.Fatalf("replay failed with %d: %s", replayW.Code, replayW.Body.String())
}
var resp replayResponse
json.NewDecoder(replayW.Body).Decode(&resp)
if resp.Status != 200 {
t.Errorf("replay response status: %d", resp.Status)
}
}
func TestIsPrivateAddr(t *testing.T) {
tests := []struct {
addr string
want bool
}{
{"localhost:8080", true},
{"127.0.0.1:9090", true},
{"[::1]:80", true},
{"10.0.0.5:443", true},
{"172.16.0.1:80", true},
{"192.168.1.1:3000", true},
{"0.0.0.0:8080", true},
{"example.com:443", false},
{"93.184.216.34:80", false},
{"8.8.8.8:53", false},
}
for _, tc := range tests {
got := isPrivateAddr(tc.addr)
if got != tc.want {
t.Errorf("isPrivateAddr(%q) = %v, want %v", tc.addr, tc.want, got)
}
}
}