diff --git a/.gitignore b/.gitignore index 425aea20..3747f69c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,16 +8,9 @@ *.out go.work -# Build artifacts +# Build /bin /dist -/hatch -/hatch-* -bin/ -*.db -*.db-journal -*.db-wal -*.db-shm # IDE .idea/ @@ -26,3 +19,5 @@ bin/ *.swo *~ .DS_Store +/hatch +hatch.exe diff --git a/brand/banner/readme-banner.png b/brand/banner/readme-banner.png new file mode 100644 index 00000000..c4091e62 Binary files /dev/null and b/brand/banner/readme-banner.png differ diff --git a/brand/banner/readme-banner.svg b/brand/banner/readme-banner.svg new file mode 100644 index 00000000..9ec41db3 --- /dev/null +++ b/brand/banner/readme-banner.svg @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + hatch + + + Self-hostable HTTP request inspector. One command. + + $ docker compose up --build + + + + + + POST /hook + + + + + + + + + + + + + + + + + + 200 14:32:01.041 + + diff --git a/brand/banner/readme-banner@2x.png b/brand/banner/readme-banner@2x.png new file mode 100644 index 00000000..ed1d613e Binary files /dev/null and b/brand/banner/readme-banner@2x.png differ diff --git a/brand/favicon/favicon-16.png b/brand/favicon/favicon-16.png new file mode 100644 index 00000000..f1b88871 Binary files /dev/null and b/brand/favicon/favicon-16.png differ diff --git a/brand/favicon/favicon-192.png b/brand/favicon/favicon-192.png new file mode 100644 index 00000000..d531ad34 Binary files /dev/null and b/brand/favicon/favicon-192.png differ diff --git a/brand/favicon/favicon-32.png b/brand/favicon/favicon-32.png new file mode 100644 index 00000000..ac18d17d Binary files /dev/null and b/brand/favicon/favicon-32.png differ diff --git a/brand/favicon/favicon-48.png b/brand/favicon/favicon-48.png new file mode 100644 index 00000000..2ed62cb2 Binary files /dev/null and b/brand/favicon/favicon-48.png differ diff --git a/brand/favicon/favicon-512.png b/brand/favicon/favicon-512.png new file mode 100644 index 00000000..15182def Binary files /dev/null and b/brand/favicon/favicon-512.png differ diff --git a/brand/favicon/favicon.ico b/brand/favicon/favicon.ico new file mode 100644 index 00000000..0d92607a Binary files /dev/null and b/brand/favicon/favicon.ico differ diff --git a/brand/mark/mark-1024.png b/brand/mark/mark-1024.png new file mode 100644 index 00000000..ef463946 Binary files /dev/null and b/brand/mark/mark-1024.png differ diff --git a/brand/mark/mark-128.png b/brand/mark/mark-128.png new file mode 100644 index 00000000..28177cc5 Binary files /dev/null and b/brand/mark/mark-128.png differ diff --git a/brand/mark/mark-16.png b/brand/mark/mark-16.png new file mode 100644 index 00000000..f1b88871 Binary files /dev/null and b/brand/mark/mark-16.png differ diff --git a/brand/mark/mark-192.png b/brand/mark/mark-192.png new file mode 100644 index 00000000..d531ad34 Binary files /dev/null and b/brand/mark/mark-192.png differ diff --git a/brand/mark/mark-256.png b/brand/mark/mark-256.png new file mode 100644 index 00000000..258d57ac Binary files /dev/null and b/brand/mark/mark-256.png differ diff --git a/brand/mark/mark-32.png b/brand/mark/mark-32.png new file mode 100644 index 00000000..ac18d17d Binary files /dev/null and b/brand/mark/mark-32.png differ diff --git a/brand/mark/mark-48.png b/brand/mark/mark-48.png new file mode 100644 index 00000000..2ed62cb2 Binary files /dev/null and b/brand/mark/mark-48.png differ diff --git a/brand/mark/mark-512.png b/brand/mark/mark-512.png new file mode 100644 index 00000000..15182def Binary files /dev/null and b/brand/mark/mark-512.png differ diff --git a/brand/mark/mark-64.png b/brand/mark/mark-64.png new file mode 100644 index 00000000..27879df2 Binary files /dev/null and b/brand/mark/mark-64.png differ diff --git a/brand/mark/mark-dark.svg b/brand/mark/mark-dark.svg new file mode 100644 index 00000000..6a8bd213 --- /dev/null +++ b/brand/mark/mark-dark.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + diff --git a/brand/mark/mark-on-dark.svg b/brand/mark/mark-on-dark.svg new file mode 100644 index 00000000..bade22f9 --- /dev/null +++ b/brand/mark/mark-on-dark.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + diff --git a/brand/mark/mark-on-light.svg b/brand/mark/mark-on-light.svg new file mode 100644 index 00000000..04835eb6 --- /dev/null +++ b/brand/mark/mark-on-light.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + diff --git a/brand/mark/mark.svg b/brand/mark/mark.svg new file mode 100644 index 00000000..7b93d442 --- /dev/null +++ b/brand/mark/mark.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + diff --git a/brand/og/og.png b/brand/og/og.png new file mode 100644 index 00000000..d7603e0a Binary files /dev/null and b/brand/og/og.png differ diff --git a/brand/og/og.svg b/brand/og/og.svg new file mode 100644 index 00000000..9503c94a --- /dev/null +++ b/brand/og/og.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + hatch + + + Self-hostable HTTP request inspector. One command. + + + + + + POST /hook + + + + + + + + + + + + + + + + + + 200 14:32:01.041 + + + github.com/elfoundation/hatch + diff --git a/brand/og/og@2x.png b/brand/og/og@2x.png new file mode 100644 index 00000000..cdf2d022 Binary files /dev/null and b/brand/og/og@2x.png differ diff --git a/brand/wordmark/wordmark-1200x360.png b/brand/wordmark/wordmark-1200x360.png new file mode 100644 index 00000000..332be82a Binary files /dev/null and b/brand/wordmark/wordmark-1200x360.png differ diff --git a/brand/wordmark/wordmark-200x60.png b/brand/wordmark/wordmark-200x60.png new file mode 100644 index 00000000..22a99028 Binary files /dev/null and b/brand/wordmark/wordmark-200x60.png differ diff --git a/brand/wordmark/wordmark-400x120.png b/brand/wordmark/wordmark-400x120.png new file mode 100644 index 00000000..462ab5ac Binary files /dev/null and b/brand/wordmark/wordmark-400x120.png differ diff --git a/brand/wordmark/wordmark-800x240.png b/brand/wordmark/wordmark-800x240.png new file mode 100644 index 00000000..09ed777d Binary files /dev/null and b/brand/wordmark/wordmark-800x240.png differ diff --git a/brand/wordmark/wordmark-dark.svg b/brand/wordmark/wordmark-dark.svg new file mode 100644 index 00000000..4216a271 --- /dev/null +++ b/brand/wordmark/wordmark-dark.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + hatch + + diff --git a/brand/wordmark/wordmark-on-dark.svg b/brand/wordmark/wordmark-on-dark.svg new file mode 100644 index 00000000..63506937 --- /dev/null +++ b/brand/wordmark/wordmark-on-dark.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + hatch + + diff --git a/brand/wordmark/wordmark-on-light.svg b/brand/wordmark/wordmark-on-light.svg new file mode 100644 index 00000000..ea031c89 --- /dev/null +++ b/brand/wordmark/wordmark-on-light.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + hatch + + diff --git a/brand/wordmark/wordmark.svg b/brand/wordmark/wordmark.svg new file mode 100644 index 00000000..8456389d --- /dev/null +++ b/brand/wordmark/wordmark.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + hatch + + diff --git a/cmd/hatch/main.go b/cmd/hatch/main.go index 493c035b..7763966f 100644 --- a/cmd/hatch/main.go +++ b/cmd/hatch/main.go @@ -1,8 +1,3 @@ -// Command hatch is the Hatch HTTP request inspector + mocker server. -// -// Hatch captures, inspects, and mocks HTTP requests. It ships as a single -// static binary — one command on a VPS and your payloads never leave your -// network. package main import ( @@ -10,6 +5,10 @@ import ( "log" "net/http" "os" + + "github.com/elfoundation/hatch/internal/handler" + "github.com/elfoundation/hatch/internal/store" + "github.com/go-chi/chi/v5" ) func main() { @@ -17,20 +16,20 @@ func main() { if port == "" { port = "8080" } + dbPath := os.Getenv("HATCH_DB_PATH") + repo, err := store.Open(dbPath) + if err != nil { + log.Fatalf("hatch: open store: %v", err) + } + defer repo.Close() - mux := http.NewServeMux() - mux.HandleFunc("GET /healthz", healthz) + h := handler.New(repo) + r := chi.NewRouter() + h.RegisterRoutes(r) addr := fmt.Sprintf(":%s", port) log.Printf("hatch starting on %s", addr) - if err := http.ListenAndServe(addr, mux); err != nil { + if err := http.ListenAndServe(addr, r); err != nil { log.Fatalf("hatch server error: %v", err) } } - -// healthz returns 200 OK with body "ok" for liveness probes. -func healthz(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain") - w.WriteHeader(http.StatusOK) - fmt.Fprintln(w, "ok") -} diff --git a/cmd/hatch/main_test.go b/cmd/hatch/main_test.go index f9d5bb5e..94bcbb22 100644 --- a/cmd/hatch/main_test.go +++ b/cmd/hatch/main_test.go @@ -1,39 +1,63 @@ package main import ( + "context" + "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" "strings" "testing" + "time" + + "github.com/elfoundation/hatch/internal/handler" + "github.com/elfoundation/hatch/internal/store" + "github.com/go-chi/chi/v5" ) -func TestHealthz(t *testing.T) { +func TestHealthzViaRouter(t *testing.T) { + repo, err := store.Open(":memory:") + if err != nil { + t.Fatalf("open store: %v", err) + } + defer repo.Close() + + r := chi.NewRouter() + h := handler.New(repo) + h.RegisterRoutes(r) + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) w := httptest.NewRecorder() + r.ServeHTTP(w, req) - healthz(w, req) - - resp := w.Result() - if resp.StatusCode != http.StatusOK { - t.Fatalf("expected 200 OK, got %d", resp.StatusCode) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d", w.Code) } - - body, err := io.ReadAll(resp.Body) - resp.Body.Close() - if err != nil { - t.Fatalf("failed to read response body: %v", err) - } - + body, _ := io.ReadAll(w.Result().Body) + w.Result().Body.Close() got := strings.TrimSpace(string(body)) if got != "ok" { - t.Fatalf("expected body 'ok', got '%s'", got) + t.Fatalf("expected body 'ok', got %q", got) } } func TestHealthzSmoke(t *testing.T) { - // Smoke test: boot the server on a random port, hit /healthz, verify 200 + "ok". - srv := httptest.NewServer(http.HandlerFunc(healthz)) + // Smoke: boot server on random port, hit /healthz. + // Use :memory: store so no filesystem dependency. + t.Setenv("HATCH_DB_PATH", ":memory:") + + repo, err := store.Open(":memory:") + if err != nil { + t.Fatalf("open store: %v", err) + } + defer repo.Close() + + r := chi.NewRouter() + h := handler.New(repo) + h.RegisterRoutes(r) + + srv := httptest.NewServer(r) defer srv.Close() resp, err := http.Get(srv.URL + "/healthz") @@ -45,14 +69,388 @@ func TestHealthzSmoke(t *testing.T) { if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200 OK, got %d", resp.StatusCode) } - body, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("failed to read response body: %v", err) } - got := strings.TrimSpace(string(body)) if got != "ok" { - t.Fatalf("expected body 'ok', got '%s'", got) + t.Fatalf("expected body 'ok', got %q", got) } } + +// TestSmokeE2E is the acceptance gate: one command to prove the whole product works. +// It starts a real server, exercises every feature (capture, inspect, SSE, mock, +// replay), and verifies the full flow end-to-end. +// +// Run: go test ./cmd/hatch -run TestSmokeE2E -v +func TestSmokeE2E(t *testing.T) { + // ---- Phase 0: Start server ---- + repo, err := store.Open(":memory:") + if err != nil { + t.Fatalf("open store: %v", err) + } + defer repo.Close() + + r := chi.NewRouter() + h := handler.New(repo) + h.RegisterRoutes(r) + + srv := httptest.NewServer(r) + defer srv.Close() + + client := &http.Client{Timeout: 10 * time.Second} + + // ---- Phase 1: Health check ---- + t.Run("healthz", func(t *testing.T) { + resp, err := client.Get(srv.URL + "/healthz") + if err != nil { + t.Fatalf("GET /healthz: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + if strings.TrimSpace(string(body)) != "ok" { + t.Fatalf("expected 'ok', got %q", string(body)) + } + }) + + // ---- Phase 2: Capture requests (all HTTP methods) ---- + t.Run("capture", func(t *testing.T) { + methods := []struct { + method string + body string + }{ + {"GET", ""}, + {"POST", `{"order":"latte"}`}, + {"PUT", `{"status":"updated"}`}, + {"PATCH", `{"field":"partial"}`}, + {"DELETE", ""}, + } + for _, m := range methods { + var body io.Reader + if m.body != "" { + body = strings.NewReader(m.body) + } + req, _ := http.NewRequest(m.method, srv.URL+"/capture-test", body) + if m.body != "" { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("X-Custom", "smoke-"+m.method) + + resp, err := client.Do(req) + if err != nil { + t.Fatalf("%s /capture-test: %v", m.method, err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("%s: expected 200, got %d", m.method, resp.StatusCode) + } + } + + // Verify all 5 requests are in the store. + reqs, err := repo.ListRequests(context.Background(), "capture-test", 10) + if err != nil { + t.Fatalf("list requests: %v", err) + } + if len(reqs) != 5 { + t.Fatalf("expected 5 captured requests, got %d", len(reqs)) + } + }) + + // ---- Phase 3: Inspect page (HTML rendering) ---- + t.Run("inspect", func(t *testing.T) { + resp, err := client.Get(srv.URL + "/e/capture-test") + if err != nil { + t.Fatalf("GET /e/capture-test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + ct := resp.Header.Get("Content-Type") + if !strings.Contains(ct, "text/html") { + t.Fatalf("expected text/html, got %q", ct) + } + + body, _ := io.ReadAll(resp.Body) + html := string(body) + + // Page structure. + if !strings.Contains(html, "") { + t.Error("missing doctype") + } + if !strings.Contains(html, "capture-test") { + t.Error("missing endpoint ID") + } + + // Request cards. + if !strings.Contains(html, "DELETE") { + t.Error("missing DELETE method badge") + } + if !strings.Contains(html, "POST") { + t.Error("missing POST method badge") + } + + // Replay UI. + if !strings.Contains(html, "Replay") { + t.Error("missing Replay button") + } + + // SSE live-update script. + if !strings.Contains(html, "EventSource") { + t.Error("missing EventSource script") + } + + // Empty-state endpoint should show the hint. + resp2, err := client.Get(srv.URL + "/e/nonexistent") + if err != nil { + t.Fatalf("GET /e/nonexistent: %v", err) + } + defer resp2.Body.Close() + body2, _ := io.ReadAll(resp2.Body) + if !strings.Contains(string(body2), "Waiting for requests") { + t.Error("missing empty state for unused endpoint") + } + }) + + // ---- Phase 4: SSE stream receives live events ---- + t.Run("sse", func(t *testing.T) { + // Use separate transports to avoid connection-pool issues with the long-lived SSE connection. + sseClient := &http.Client{ + Transport: &http.Transport{DisableKeepAlives: true}, + Timeout: 5 * time.Second, + } + captureClient := &http.Client{ + Transport: &http.Transport{DisableKeepAlives: true}, + Timeout: 5 * time.Second, + } + + // Subscribe to SSE. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sseReq, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/e/sse-ep/events", nil) + sseResp, err := sseClient.Do(sseReq) + if err != nil { + t.Fatalf("SSE request: %v", err) + } + defer sseResp.Body.Close() + + if sseResp.StatusCode != http.StatusOK { + t.Fatalf("SSE returned %d", sseResp.StatusCode) + } + ct := sseResp.Header.Get("Content-Type") + if !strings.HasPrefix(ct, "text/event-stream") { + t.Fatalf("SSE Content-Type: %q", ct) + } + + // Capture a request on a goroutine; read SSE on main. + eventCh := make(chan string, 1) + go func() { + buf := make([]byte, 4096) + n, _ := sseResp.Body.Read(buf) + eventCh <- string(buf[:n]) + }() + + time.Sleep(20 * time.Millisecond) // let SSE subscription register + + resp, err := captureClient.Post(srv.URL+"/sse-ep", "application/json", strings.NewReader(`{"sse":true}`)) + if err != nil { + t.Fatalf("capture: %v", err) + } + resp.Body.Close() + + select { + case event := <-eventCh: + if !strings.Contains(event, "data:") { + t.Fatalf("SSE event missing 'data:' prefix: %q", event) + } + // Body value is JSON-escaped in the SSE stream (e.g. "body":"{\"sse\":true}"). + // Check for the content values rather than the exact escaped form. + if !strings.Contains(event, `sse`) || !strings.Contains(event, `true`) { + t.Fatalf("SSE event missing body content: %q", event) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for SSE event") + } + }) + + // ---- Phase 5: Mock configuration and response ---- + t.Run("mock", func(t *testing.T) { + // Set a mock on a fresh endpoint. + mockBody := `{"status":418,"headers":{"X-Teapot":"true"},"body":"eyJicmV3aW5nIjp0cnVlfQ=="}` + + resp, err := client.Post( + srv.URL+"/e/mock-ep/mock", + "application/json", + strings.NewReader(mockBody), + ) + if err != nil { + t.Fatalf("PUT /e/mock-ep/mock: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + // PUT is registered as PUT, but we use POST here — let's use PUT. + // The route is PUT /e/{endpointID}/mock + } + + // Use proper PUT method. + req, _ := http.NewRequest(http.MethodPut, srv.URL+"/e/mock-ep-2/mock", strings.NewReader(mockBody)) + req.Header.Set("Content-Type", "application/json") + resp2, err := client.Do(req) + if err != nil { + t.Fatalf("PUT /e/mock-ep-2/mock: %v", err) + } + resp2.Body.Close() + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200 OK from mock set, got %d: %s", resp2.StatusCode, readBody(resp2)) + } + + // Verify mock is stored. + mock, err := repo.GetMock(context.Background(), "mock-ep-2") + if err != nil { + t.Fatalf("mock not stored: %v", err) + } + if mock.Status != 418 { + t.Errorf("expected status 418, got %d", mock.Status) + } + if mock.Headers["X-Teapot"] != "true" { + t.Errorf("expected X-Teapot header, got %v", mock.Headers) + } + + // Capture a request — should return the mock response. + resp3, err := client.Post(srv.URL+"/mock-ep-2", "application/json", strings.NewReader(`{"order":"earl grey"}`)) + if err != nil { + t.Fatalf("capture on mocked endpoint: %v", err) + } + defer resp3.Body.Close() + if resp3.StatusCode != 418 { + t.Fatalf("expected mock status 418, got %d", resp3.StatusCode) + } + if resp3.Header.Get("X-Teapot") != "true" { + t.Errorf("expected X-Teapot: true, got %q", resp3.Header.Get("X-Teapot")) + } + body, _ := io.ReadAll(resp3.Body) + // Body is base64-decoded by Go's json decoder ([]byte field). + if strings.TrimSpace(string(body)) != `{"brewing":true}` { + t.Errorf("expected mock body, got %q", string(body)) + } + }) + + // ---- Phase 6: Replay ---- + t.Run("replay", func(t *testing.T) { + // Start a 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) + fmt.Fprintf(w, `{"echo":{"method":%q,"path":%q}}`, r.Method, r.URL.Path) + })) + defer sink.Close() + + // Allow private replay so we can hit the loopback sink. + t.Setenv("HATCH_ALLOW_PRIVATE_REPLAY", "true") + + // Capture a request to replay later. + captureResp, err := client.Post(srv.URL+"/replay-ep", "application/json", strings.NewReader(`{"msg":"replay me"}`)) + if err != nil { + t.Fatalf("capture for replay: %v", err) + } + captureResp.Body.Close() + + // Get the captured request ID from the store. + reqs, _ := repo.ListRequests(context.Background(), "replay-ep", 1) + if len(reqs) != 1 { + t.Fatalf("expected 1 captured request, got %d", len(reqs)) + } + reqID := reqs[0].ID + + // Replay to the sink. + replayPayload := fmt.Sprintf(`{"target_url":%q}`, sink.URL) + replayResp, err := client.Post( + srv.URL+"/e/replay-ep/requests/"+reqID+"/replay", + "application/json", + strings.NewReader(replayPayload), + ) + if err != nil { + t.Fatalf("replay request: %v", err) + } + defer replayResp.Body.Close() + if replayResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(replayResp.Body) + t.Fatalf("replay returned %d: %s", replayResp.StatusCode, string(body)) + } + + var result struct { + Status int `json:"status"` + Headers map[string]string `json:"headers"` + Body string `json:"body"` + } + json.NewDecoder(replayResp.Body).Decode(&result) + if result.Status != 201 { + t.Errorf("expected replay sink status 201, got %d", result.Status) + } + if result.Headers["X-Sink"] != "yes" { + t.Errorf("expected X-Sink: yes, got %q", result.Headers["X-Sink"]) + } + }) + + // ---- Phase 7: Edge cases ---- + t.Run("edge", func(t *testing.T) { + // Capture with query params. + resp, err := client.Get(srv.URL + "/query-ep?foo=bar&baz=qux") + if err != nil { + t.Fatalf("capture with query: %v", err) + } + resp.Body.Close() + + reqs, _ := repo.ListRequests(context.Background(), "query-ep", 1) + if len(reqs) != 1 { + t.Fatalf("expected 1 request, got %d", len(reqs)) + } + if reqs[0].Query != "foo=bar&baz=qux" { + t.Errorf("expected query 'foo=bar&baz=qux', got %q", reqs[0].Query) + } + + // Capture with binary body. + binaryBody := []byte{0x00, 0x01, 0x02, 0xFF, 0xFE} + resp2, err := client.Post(srv.URL+"/binary-ep", "application/octet-stream", strings.NewReader(string(binaryBody))) + if err != nil { + t.Fatalf("binary capture: %v", err) + } + resp2.Body.Close() + + reqs2, _ := repo.ListRequests(context.Background(), "binary-ep", 1) + if len(reqs2) != 1 { + t.Fatalf("expected 1 binary request, got %d", len(reqs2)) + } + if len(reqs2[0].Body) != len(binaryBody) { + t.Errorf("expected body len %d, got %d", len(binaryBody), len(reqs2[0].Body)) + } + + // SSRF protection: replay to localhost is denied. + replayPayload := `{"target_url":"http://localhost:9999/"}` + req, _ := http.NewRequest(http.MethodPost, srv.URL+"/e/query-ep/requests/"+reqs[0].ID+"/replay", strings.NewReader(replayPayload)) + req.Header.Set("Content-Type", "application/json") + resp3, err := client.Do(req) + if err != nil { + t.Fatalf("SSRF test: %v", err) + } + resp3.Body.Close() + if resp3.StatusCode != http.StatusForbidden { + t.Errorf("expected 403 for localhost replay, got %d", resp3.StatusCode) + } + }) + + t.Log("E2E smoke test complete — all phases passed") +} + +func readBody(resp *http.Response) string { + if resp.Body == nil { + return "" + } + b, _ := io.ReadAll(resp.Body) + return string(b) +} diff --git a/docs/brand/usage.md b/docs/brand/usage.md new file mode 100644 index 00000000..c2c0d0c5 --- /dev/null +++ b/docs/brand/usage.md @@ -0,0 +1,142 @@ +# Hatch brand usage + +> One-pager. Read this before you put the wordmark anywhere. + +## Files in this kit + +``` +brand/ +├── wordmark/ +│ ├── wordmark.svg # master wordmark (text-only, with embedded font) +│ ├── wordmark-dark.svg # dark variant (paper on transparent) +│ ├── wordmark-on-light.svg # paper-on-warm-white background +│ ├── wordmark-on-dark.svg # paper-on-ink background +│ └── wordmark-*.png # 200x60, 400x120, 800x240, 1200x360 +├── mark/ +│ ├── mark.svg # the icon alone (no text) +│ ├── mark-on-light.svg +│ ├── mark-on-dark.svg +│ └── mark-{16,32,48,64,128,192,256,512,1024}.png +├── favicon/ +│ ├── favicon.ico # 16+32+48 multi-size ico +│ └── favicon-{16,32,48,192,512}.png +├── banner/ +│ ├── readme-banner.png # 1280x640 +│ └── readme-banner@2x.png # 2560x1280 +└── og/ + ├── og.png # 1200x630 + └── og@2x.png # 2400x1260 +``` + +## What goes where + +| Surface | Use | File | +| ---------------------------------------- | -------------------------------- | -------------------------------------- | +| Favicon (browser tab, GitHub avatar) | The **mark**, not the wordmark | `brand/favicon/favicon.ico` | +| Apple touch icon / PWA icon | The mark, 512px | `brand/favicon/favicon-512.png` | +| README header | The **wordmark** (200x60+) | `brand/wordmark/wordmark-200x60.png` | +| GitHub social preview / blog / link card | The **OG image** (1200x630) | `brand/og/og.png` | +| Big surfaces, slides, README top | The **README banner** (1280x640) | `brand/banner/readme-banner.png` | + +For the favicon, always use `mark.svg` or one of the favicon PNGs — never the full +wordmark. The wordmark does not read at 16x16. + +## Clear space + +Treat the mark as a square and the wordmark as the mark + the word. Clear space +is **1× the cap-height of the "h" in "hatch"** on every side. + +- Wordmark: at least one "h"-height of empty space on top, bottom, left, and right +- Mark: at least one "h"-height on every side + +``` + ┌──────────────────────────────┐ + │ │ ← 1× clear space + │ ┌──┐ hatch │ + │ │ │ │ + │ └──┘ │ + │ │ ← 1× clear space + └──────────────────────────────┘ +``` + +## Minimum size + +- Wordmark: **120px wide** is the minimum. Below that, switch to the mark. +- Mark: **16px** is the minimum (we ship a tuned 16px favicon). + +## Color + +The brand has **one active color, one neutral, and one accent**. That's it. + +| Token | Hex | Use | +| -------- | --------- | ------------------------------------------------- | +| Ink | `#0A0A0B` | Wordmark, primary type, dark surfaces | +| Paper | `#FAFAF9` | Light surfaces, type on dark | +| Accent | `#F59E0B` | The "captured" signal — one place per composition | + +Neutral scale (for docs and UIs, not the wordmark): `#71717A` `#A1A1AA` `#D4D4D8` `#E4E4E7` `#F4F4F5`. + +**Don't pick colors outside the kit.** If you need another color, ask the Head of +Marketing; do not invent one. + +## Light and dark mode + +The wordmark and the mark are **single-color**. Pick the right variant for the +surface — never change the color of the wordmark to fit a theme. + +- **On a light surface** (≥ 4.5:1 contrast): use `wordmark.svg` (ink) or `mark.svg` +- **On a dark surface**: use `wordmark-dark.svg` (paper) or `mark-dark.svg` +- **Need a background tile?** Use `wordmark-on-light.svg` or `wordmark-on-dark.svg` + +For GitHub READMEs, GitHub auto-switches images in dark mode only if you use +the `#gh-dark-mode-only` / `#gh-light-mode-only` MediaFragments trick: + +```markdown + + + Hatch + +``` + +## Typography (display + body) + +The wordmark uses **Inter Bold**. The brand voice guide and READMEs use: + +- **Display / headings:** Inter (Bold or SemiBold) +- **Code / mono:** JetBrains Mono (Regular or Medium) + +Both are OFL-licensed and on the system. Don't substitute a different sans or mono +in brand surfaces without sign-off. + +## Do not + +- **Don't stretch.** Always use the mark/wordmark at its native aspect ratio. If + you need it bigger, scale proportionally. +- **Don't recolor.** The wordmark is ink or paper — period. No brand-color, + gradient, rainbow, or AI-painted versions. +- **Don't add effects.** No drop shadows, no glows, no outlines, no bevel, no + inner shadows. The mark is a flat shape. +- **Don't rotate.** The hatch opens at the top. Don't tilt the mark. +- **Don't rearrange the wordmark.** The mark always sits to the **left** of the + wordmark, with a 0.22×-mark-width gap. No stacking, no right-alignment. +- **Don't put the wordmark on a busy image.** The wordmark needs contrast. On a + photo or a complex background, use a solid color tile behind it. +- **Don't use the wordmark in body copy.** The wordmark is for headers, banners, + and brand surfaces only. Body type stays in Inter. +- **Don't use the favicon as a profile picture.** Use the 512px PNG (`mark-512.png`) + for that, and round the corners if you want — but the square mark is the + canonical shape. + +## Licensing & attribution + +- **Inter** — SIL Open Font License 1.1 (Inter by Rasmus Andersson, rsms.me) +- **JetBrains Mono** — SIL Open Font License 1.1 (JetBrains) +- The mark and wordmark are the property of El Foundation. Use them to talk about + Hatch. Do not register them as a trademark or use them to sell a competing + product. + +## Need something new? + +If a new surface needs a brand asset that isn't in the kit (a slide template, a +business card, a T-shirt, an icon variant), open an issue assigned to the Brand +Designer. Do not stretch, recolor, or rearrange this kit to make it work. diff --git a/go.mod b/go.mod index a0431d6b..504323fd 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,20 @@ module github.com/elfoundation/hatch go 1.25.0 + +require ( + github.com/go-chi/chi/v5 v5.3.0 + github.com/google/uuid v1.6.0 + modernc.org/sqlite v1.53.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.44.0 // indirect + modernc.org/libc v1.73.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum index e69de29b..860a5699 100644 --- a/go.sum +++ b/go.sum @@ -0,0 +1,53 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= +github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c= +modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws= +modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc= +modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= +modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= +modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/handler/handler.go b/internal/handler/handler.go new file mode 100644 index 00000000..40b90ecf --- /dev/null +++ b/internal/handler/handler.go @@ -0,0 +1,125 @@ +package handler + +import ( + "encoding/json" + "io" + "net/http" + "strings" + + "github.com/elfoundation/hatch/internal/store" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) + +// Handler groups all HTTP handlers and their shared dependencies. +type Handler struct { + Repo store.Repository +} + +// New creates a new Handler with the given store. +func New(repo store.Repository) *Handler { + return &Handler{Repo: repo} +} + +// RegisterRoutes mounts all routes on the given chi router. +func (h *Handler) RegisterRoutes(r chi.Router) { + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + + // Health check. + r.Get("/healthz", Healthz) + + // Inspect page: server-rendered request list. + r.Get("/e/{endpointID}", HandleInspect(h.Repo)) + + // SSE stream for live updates. + r.Get("/e/{endpointID}/events", HandleSSE(h.Repo)) + + // Mock configuration. + r.Put("/e/{endpointID}/mock", HandleMock(h.Repo)) + + // Replay: one-click re-send of a captured request. + r.Post("/e/{endpointID}/requests/{requestID}/replay", HandleReplay(h.Repo)) + + // Capture webhook: any method on /{endpointID} (wildcard, must be last). + r.HandleFunc("/{endpointID}", h.capture) + r.HandleFunc("/{endpointID}/*", h.capture) +} + +// capture handles incoming webhook captures on /{endpointID}. +func (h *Handler) capture(w http.ResponseWriter, r *http.Request) { + eid := r.PathValue("endpointID") + if eid == "" { + writeError(w, http.StatusBadRequest, "missing endpoint ID") + return + } + + ctx := r.Context() + + // Auto-create endpoint if it doesn't exist. + if _, err := h.Repo.GetEndpoint(ctx, eid); err != nil { + h.Repo.CreateEndpoint(ctx, eid) + } + + // Collect headers as JSON. + hdr := map[string]string{} + for k, v := range r.Header { + hdr[k] = strings.Join(v, ", ") + } + hdrJSON, _ := json.Marshal(hdr) + + // Read body. + var body []byte + if r.Body != nil { + body, _ = io.ReadAll(r.Body) + r.Body.Close() + } + + // Store the request. + req := &store.Request{ + Method: r.Method, + Path: r.URL.Path, + Headers: string(hdrJSON), + Query: r.URL.RawQuery, + Body: body, + } + if err := h.Repo.AppendRequest(ctx, eid, req); err != nil { + writeError(w, http.StatusInternalServerError, "failed to store request") + return + } + + // Broadcast via SSE. + broadcastRequest(eid, req) + + // Look up mock response for this endpoint. + mock, err := h.Repo.GetMock(ctx, eid) + if err == nil && mock != nil { + for k, v := range mock.Headers { + w.Header().Set(k, v) + } + w.WriteHeader(mock.Status) + if mock.Body != nil { + w.Write(mock.Body) + } + return + } + + // Default: return 200 OK with empty JSON. + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{"ok": true}) +} + +// Healthz returns 200 OK for liveness probes. +func Healthz(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok\n")) +} + +// writeError writes a JSON error response. +func writeError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go new file mode 100644 index 00000000..d11f69e4 --- /dev/null +++ b/internal/handler/handler_test.go @@ -0,0 +1,442 @@ +package handler + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/elfoundation/hatch/internal/store" + "github.com/go-chi/chi/v5" +) + +// fakeRepo implements store.Repository for tests. +type fakeRepo struct { + endpoints map[string]*store.Endpoint + requests []*store.Request + mocks map[string]*store.MockConfig +} + +func newFakeRepo() *fakeRepo { + return &fakeRepo{ + endpoints: map[string]*store.Endpoint{}, + mocks: map[string]*store.MockConfig{}, + } +} + +func (f *fakeRepo) CreateEndpoint(_ context.Context, u string) (*store.Endpoint, error) { + e := &store.Endpoint{ID: u, URL: u, CreatedAt: "t", UpdatedAt: "t"} + f.endpoints[u] = e + return e, nil +} +func (f *fakeRepo) GetEndpoint(_ context.Context, id string) (*store.Endpoint, error) { + e, ok := f.endpoints[id] + if !ok { + return nil, errNotFound + } + return e, nil +} +func (f *fakeRepo) AppendRequest(_ context.Context, eid string, r *store.Request) error { + r.ID = "req-" + string(rune(len(f.requests)+'0')) + r.EndpointID = eid + f.requests = append(f.requests, r) + return nil +} +func (f *fakeRepo) GetRequest(_ context.Context, id string) (*store.Request, error) { + for _, r := range f.requests { + if r.ID == id { + return r, nil + } + } + return nil, errNotFound +} +func (f *fakeRepo) ListRequests(_ context.Context, _ string, _ int) ([]*store.Request, error) { + return f.requests, nil +} +func (f *fakeRepo) GetMock(_ context.Context, endpointID string) (*store.MockConfig, error) { + m, ok := f.mocks[endpointID] + if !ok { + return nil, errNotFound + } + return m, nil +} +func (f *fakeRepo) SetMock(_ context.Context, mock *store.MockConfig) error { + f.mocks[mock.EndpointID] = mock + return nil +} +func (f *fakeRepo) Close() error { return nil } + +var errNotFound = &se{"nf"} + +type se struct{ m string } + +func (e *se) Error() string { return e.m } + +// testRouter creates a chi router with all routes registered using a fake repo. +func testRouter(repo store.Repository) chi.Router { + r := chi.NewRouter() + h := New(repo) + h.RegisterRoutes(r) + return r +} + +func TestHealthz(t *testing.T) { + r := testRouter(newFakeRepo()) + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + body := strings.TrimSpace(w.Body.String()) + if body != "ok" { + t.Fatalf("expected 'ok', got %q", body) + } +} + +func TestCaptureRecordsAllVerbs(t *testing.T) { + methods := []string{"GET", "POST", "PUT", "PATCH", "DELETE"} + for _, m := range methods { + repo := newFakeRepo() + r := testRouter(repo) + + body := "" + if m == "POST" || m == "PUT" || m == "PATCH" { + body = `{"k":"v"}` + } + req := httptest.NewRequest(m, "/ep", strings.NewReader(body)) + if m == "GET" { + req.Header.Set("Accept", "text/html") + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("%s: expected 200, got %d", m, w.Code) + } + if len(repo.requests) != 1 { + t.Errorf("%s: expected 1 request, got %d", m, len(repo.requests)) + continue + } + reqCaptured := repo.requests[0] + if reqCaptured.Method != m { + t.Errorf("%s: wrong method %s", m, reqCaptured.Method) + } + } +} + +func TestSSEStreamReceivesEventOnCapture(t *testing.T) { + repo := newFakeRepo() + srv := httptest.NewServer(testRouter(repo)) + defer srv.Close() + + // Use separate clients with separate transports to avoid + // connection-pool contention with the long-lived SSE connection. + sseClient := &http.Client{ + Transport: &http.Transport{DisableKeepAlives: true}, + } + captureClient := &http.Client{ + Transport: &http.Transport{DisableKeepAlives: true}, + } + + // Subscribe to SSE with a cancellable context. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sseReq, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/e/ep/events", nil) + if err != nil { + t.Fatalf("SSE request create failed: %v", err) + } + sseResp, err := sseClient.Do(sseReq) + if err != nil { + t.Fatalf("SSE GET failed: %v", err) + } + defer sseResp.Body.Close() + + if sseResp.StatusCode != http.StatusOK { + t.Fatalf("SSE returned %d", sseResp.StatusCode) + } + ct := sseResp.Header.Get("Content-Type") + if !strings.HasPrefix(ct, "text/event-stream") { + t.Fatalf("SSE Content-Type is %q, expected text/event-stream", ct) + } + + // Read SSE body in a goroutine; capture in main goroutine. + bodyCh := make(chan string, 1) + go func() { + buf := make([]byte, 4096) + n, _ := sseResp.Body.Read(buf) + bodyCh <- string(buf[:n]) + }() + + // Small sleep to let the SSE subscription register. + time.Sleep(10 * time.Millisecond) + + // Capture a request on the same server. + captureResp, err := captureClient.Post(srv.URL+"/ep", "application/json", strings.NewReader(`{"msg":"hello"}`)) + if err != nil { + t.Fatalf("capture POST failed: %v", err) + } + captureResp.Body.Close() + if captureResp.StatusCode != http.StatusOK { + t.Fatalf("capture returned %d", captureResp.StatusCode) + } + + // Wait for the SSE event with a timeout. + select { + case body := <-bodyCh: + if !strings.Contains(body, "data:") { + t.Fatalf("SSE stream missing 'data:' prefix: %q", body) + } + if !strings.Contains(body, "\"method\":\"POST\"") { + t.Fatalf("SSE stream missing POST method: %q", body) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for SSE event") + } + + // Cancel context to close the SSE connection. + cancel() +} + +func TestInspectReturnsHTML(t *testing.T) { + repo := newFakeRepo() + repo.CreateEndpoint(nil, "ep") + repo.AppendRequest(nil, "ep", &store.Request{ + Method: "POST", + Path: "/ep/webhook", + Headers: `{"Content-Type":"application/json"}`, + Query: "foo=bar", + Body: []byte(`{"msg":"hello"}`), + }) + + r := testRouter(repo) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/e/ep", nil)) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + ct := w.Header().Get("Content-Type") + if !strings.Contains(ct, "text/html") { + t.Fatalf("expected text/html Content-Type, got %q", ct) + } + + body := w.Body.String() + + // Should contain the endpoint ID. + if !strings.Contains(body, "ep") { + t.Error("missing endpoint ID in HTML") + } + + // Should contain the request method badge. + if !strings.Contains(body, "POST") { + t.Error("missing POST method in HTML") + } + + // Should contain the request path. + if !strings.Contains(body, "/ep/webhook") { + t.Error("missing request path in HTML") + } + + // Should contain the header JSON. + if !strings.Contains(body, "Content-Type") { + t.Error("missing Content-Type header in HTML") + } + + // Should contain the query string. + if !strings.Contains(body, "foo=bar") { + t.Error("missing query string in HTML") + } + + // Should contain the body content (html/template escapes quotes). + if !strings.Contains(body, "msg") || !strings.Contains(body, "hello") { + t.Error("missing request body content in HTML") + } + + // Should contain the replay button. + if !strings.Contains(body, "Replay") { + t.Error("missing Replay button in HTML") + } + + // Should contain SSE EventSource script. + if !strings.Contains(body, "EventSource") { + t.Error("missing EventSource in HTML") + } +} + +func TestInspectEmptyState(t *testing.T) { + repo := newFakeRepo() + repo.CreateEndpoint(nil, "new-ep") + + r := testRouter(repo) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/e/new-ep", nil)) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + body := w.Body.String() + + // Should show the "waiting for requests" empty state. + if !strings.Contains(body, "Waiting for requests") { + t.Error("missing empty state message") + } + + // Should show the usage hint with the endpoint ID. + if !strings.Contains(body, "/new-ep") { + t.Error("missing usage hint with endpoint ID") + } + + // No request cards should be rendered. + if strings.Contains(body, `class="request"`) { + t.Error("unexpected request card in empty state") + } +} + +func TestInspectAutoCreatesEndpoint(t *testing.T) { + repo := newFakeRepo() + + // The endpoint doesn't exist yet. + r := testRouter(repo) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/e/auto-ep", nil)) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + // The endpoint should now exist in the repo. + ep, err := repo.GetEndpoint(nil, "auto-ep") + if err != nil { + t.Fatalf("endpoint was not auto-created: %v", err) + } + if ep.ID != "auto-ep" { + t.Errorf("expected endpoint ID 'auto-ep', got %q", ep.ID) + } + + body := w.Body.String() + if !strings.Contains(body, "auto-ep") { + t.Error("missing endpoint ID in auto-created page") + } + if !strings.Contains(body, "Waiting for requests") { + t.Error("missing empty state for auto-created endpoint") + } +} + +func TestCaptureReturnsJSON200(t *testing.T) { + r := testRouter(newFakeRepo()) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/t", nil)) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + if !strings.Contains(w.Header().Get("Content-Type"), "application/json") { + t.Error("not json") + } + data, _ := io.ReadAll(w.Result().Body) + w.Result().Body.Close() + var v map[string]interface{} + if json.Unmarshal(data, &v) != nil { + t.Fatal("invalid json") + } +} + +func TestMockSetsAndConfiguresResponse(t *testing.T) { + repo := newFakeRepo() + repo.CreateEndpoint(nil, "mock-ep") + + r := testRouter(repo) + + // Set a mock via PUT. + mockBody := `{"status": 201, "headers": {"X-Mocked": "yes"}, "body": "bW9ja2Vk"}` + req := httptest.NewRequest(http.MethodPut, "/e/mock-ep/mock", strings.NewReader(mockBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d: %s", w.Code, w.Body.String()) + } + + // Verify the mock was stored. + mock, err := repo.GetMock(nil, "mock-ep") + if err != nil { + t.Fatalf("mock not stored: %v", err) + } + if mock.Status != 201 { + t.Errorf("expected status 201, got %d", mock.Status) + } + if mock.Headers["X-Mocked"] != "yes" { + t.Errorf("expected X-Mocked header, got %v", mock.Headers) + } +} + +func TestMockReturnsConfiguredResponseOnCapture(t *testing.T) { + repo := newFakeRepo() + repo.CreateEndpoint(nil, "mock-cap") + // Pre-set a mock. + repo.SetMock(nil, &store.MockConfig{ + EndpointID: "mock-cap", + Status: 418, + Headers: map[string]string{"X-Teapot": "true"}, + Body: []byte(`{"brewing": true}`), + }) + + r := testRouter(repo) + + // Capture a request — should return the mock response, not the default 200. + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/mock-cap", strings.NewReader(`{"order":"latte"}`))) + + if w.Code != 418 { + t.Fatalf("expected mock status 418, got %d", w.Code) + } + if w.Header().Get("X-Teapot") != "true" { + t.Errorf("expected X-Teapot: true, got %q", w.Header().Get("X-Teapot")) + } + body := strings.TrimSpace(w.Body.String()) + if body != `{"brewing": true}` { + t.Errorf("expected mock body, got %q", body) + } + + // Request should still be captured even when mock responds. + if len(repo.requests) != 1 { + t.Fatalf("expected 1 captured request, got %d", len(repo.requests)) + } + if repo.requests[0].Method != "POST" { + t.Errorf("captured method: %s", repo.requests[0].Method) + } +} + +func TestMockAutoCreatesEndpointOnSet(t *testing.T) { + repo := newFakeRepo() + r := testRouter(repo) + + // Set a mock on an endpoint that doesn't exist yet. + mockBody := `{"status": 200, "headers": {}, "body": ""}` + req := httptest.NewRequest(http.MethodPut, "/e/auto-mock/mock", strings.NewReader(mockBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d: %s", w.Code, w.Body.String()) + } + + // Endpoint should be auto-created. + ep, err := repo.GetEndpoint(nil, "auto-mock") + if err != nil { + t.Fatalf("endpoint was not auto-created: %v", err) + } + if ep.ID != "auto-mock" { + t.Errorf("expected endpoint id 'auto-mock', got %q", ep.ID) + } +} diff --git a/internal/handler/inspect.go b/internal/handler/inspect.go new file mode 100644 index 00000000..03cbafb1 --- /dev/null +++ b/internal/handler/inspect.go @@ -0,0 +1,344 @@ +package handler + +import ( + "encoding/json" + "fmt" + "html/template" + "net/http" + "time" + + "github.com/elfoundation/hatch/internal/store" +) + +// inspectPageData is passed to the inspect template. +type inspectPageData struct { + EndpointID string + Requests []*store.Request +} + +// inspectTemplate is the server-rendered HTML for the inspect page. +var inspectTemplate = template.Must(template.New("inspect").Funcs(template.FuncMap{ + "prettyJSON": prettyJSON, + "formatTime": formatTime, +}).Parse(` + + + + +Hatch — {{.EndpointID}} + + + +

Hatch

+
Endpoint: {{.EndpointID}}
+ +{{if .Requests}} + {{range .Requests}} +
+
+ {{.Method}} + {{.Path}} + {{formatTime .CreatedAt}} + +
+
+ {{if .Headers}} +
+ +
{{prettyJSON .Headers}}
+
+ {{end}} + {{if .Query}} +
+ +
{{.Query}}
+
+ {{end}} + {{if .Body}} +
+ +
{{printf "%s" .Body}}
+
+ {{end}} +
+
+ +
+ + + +
+
+
+
+ {{end}} +{{else}} +
+

Waiting for requests...

+

Send a request to /{{.EndpointID}} to see it appear here.

+
+{{end}} + + + + +`)) + +// HandleInspect serves the inspect page at GET /e/{endpointID}. +func HandleInspect(repo store.Repository) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + endpointID := r.PathValue("endpointID") + if endpointID == "" { + http.Error(w, "missing endpoint ID", http.StatusBadRequest) + return + } + if _, err := repo.GetEndpoint(r.Context(), endpointID); err != nil { + repo.CreateEndpoint(r.Context(), endpointID) + } + requests, err := repo.ListRequests(r.Context(), endpointID, 100) + if err != nil { + http.Error(w, "failed to list requests", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + inspectTemplate.Execute(w, inspectPageData{ + EndpointID: endpointID, + Requests: requests, + }) + } +} + +// prettyJSON formats a JSON string with indentation. +func prettyJSON(raw string) string { + var v interface{} + if err := json.Unmarshal([]byte(raw), &v); err != nil { + return raw + } + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + return raw + } + return string(b) +} + +// formatTime displays an ISO 8601 timestamp as a short relative or absolute string. +func formatTime(raw string) string { + t, err := time.Parse("2006-01-02T15:04:05.000Z07:00", raw) + if err != nil { + // Try a few common variants. + t, err = time.Parse("2006-01-02T15:04:05Z07:00", raw) + } + if err != nil { + return raw + } + d := time.Since(t) + switch { + case d < 5*time.Second: + return "just now" + case d < time.Minute: + return fmt.Sprintf("%ds ago", int(d.Seconds())) + case d < time.Hour: + return fmt.Sprintf("%dm ago", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh ago", int(d.Hours())) + case d < 7*24*time.Hour: + return fmt.Sprintf("%dd ago", int(d.Hours()/24)) + default: + return t.Format("Jan 2, 15:04") + } +} diff --git a/internal/handler/mock.go b/internal/handler/mock.go new file mode 100644 index 00000000..2b9c4d8e --- /dev/null +++ b/internal/handler/mock.go @@ -0,0 +1,35 @@ +package handler + +import ( + "encoding/json" + "net/http" + + "github.com/elfoundation/hatch/internal/store" +) + +// HandleMock handles PUT /e/{endpointID}/mock. +func HandleMock(repo store.Repository) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + endpointID := r.PathValue("endpointID") + if endpointID == "" { + writeError(w, http.StatusBadRequest, "missing endpoint ID") + return + } + if _, err := repo.GetEndpoint(r.Context(), endpointID); err != nil { + repo.CreateEndpoint(r.Context(), endpointID) + } + var mock store.MockConfig + if err := json.NewDecoder(r.Body).Decode(&mock); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error()) + return + } + mock.EndpointID = endpointID + if err := repo.SetMock(r.Context(), &mock); err != nil { + writeError(w, http.StatusInternalServerError, "failed to set mock: "+err.Error()) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + } +} diff --git a/internal/handler/replay.go b/internal/handler/replay.go new file mode 100644 index 00000000..36884311 --- /dev/null +++ b/internal/handler/replay.go @@ -0,0 +1,196 @@ +package handler + +import ( + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "strings" + + "github.com/elfoundation/hatch/internal/store" +) + +const maxReplayBodyBytes = 64 * 1024 + +type replayRequest struct { + TargetURL string `json:"target_url"` +} + +type replayResponse struct { + Status int `json:"status"` + Headers map[string]string `json:"headers"` + Body string `json:"body"` + Error string `json:"error,omitempty"` +} + +func HandleReplay(repo store.Repository) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + requestID := r.PathValue("requestID") + if requestID == "" { + writeError(w, http.StatusBadRequest, "missing request_id") + return + } + + var replay replayRequest + if err := json.NewDecoder(r.Body).Decode(&replay); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error()) + return + } + if replay.TargetURL == "" { + writeError(w, http.StatusBadRequest, "target_url is required") + return + } + + target, err := url.Parse(replay.TargetURL) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid target_url: "+err.Error()) + return + } + if target.Scheme != "http" && target.Scheme != "https" { + writeError(w, http.StatusBadRequest, "target_url scheme must be http or https") + return + } + + if !allowPrivateReplay() && isPrivateAddr(target.Host) { + writeError(w, http.StatusForbidden, "replay to private/loopback addresses is denied. Set HATCH_ALLOW_PRIVATE_REPLAY=true to allow") + return + } + + capReq, err := repo.GetRequest(r.Context(), requestID) + if err != nil { + writeError(w, http.StatusNotFound, "request not found: "+err.Error()) + return + } + + replayResult, err := doReplay(capReq, target) + if err != nil { + writeError(w, http.StatusBadGateway, "replay failed: "+err.Error()) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(replayResult) + } +} + +func doReplay(capReq *store.Request, targetURL *url.URL) (*replayResponse, error) { + outURL := *targetURL + outURL.Path = joinPath(targetURL.Path, capReq.Path) + if capReq.Query != "" { + outURL.RawQuery = capReq.Query + } + + var bodyReader io.Reader + if capReq.Body != nil && len(capReq.Body) > 0 { + bodyReader = strings.NewReader(string(capReq.Body)) + } + + req, err := http.NewRequest(capReq.Method, outURL.String(), bodyReader) + if err != nil { + return nil, fmt.Errorf("building request: %w", err) + } + + var headers map[string]string + if capReq.Headers != "" { + if err := json.Unmarshal([]byte(capReq.Headers), &headers); err != nil { + headers = nil + } + } + for k, v := range headers { + if isHopByHop(k) { + continue + } + req.Header.Set(k, v) + } + + client := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if !allowPrivateReplay() && isPrivateAddr(req.URL.Host) { + return fmt.Errorf("redirect to private address %s denied", req.URL.Host) + } + if len(via) >= 10 { + return fmt.Errorf("too many redirects") + } + return nil + }, + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("sending request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxReplayBodyBytes)) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + respHeaders := make(map[string]string, len(resp.Header)) + for k, vs := range resp.Header { + respHeaders[k] = strings.Join(vs, ", ") + } + + return &replayResponse{ + Status: resp.StatusCode, + Headers: respHeaders, + Body: string(body), + }, nil +} + +// isPrivateAddr reports whether addr falls within a private, loopback, or reserved range. +func isPrivateAddr(hostport string) bool { + host, _, err := net.SplitHostPort(hostport) + if err != nil { + host = hostport + } + if host == "" { + return false + } + + // String-based checks first (catch names and the unspecified address 0.0.0.0). + if host == "localhost" || host == "0.0.0.0" || host == "::1" || host == "[::1]" { + return true + } + + ip := net.ParseIP(host) + if ip != nil { + return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() + } + return false +} + +func allowPrivateReplay() bool { + return strings.EqualFold(os.Getenv("HATCH_ALLOW_PRIVATE_REPLAY"), "true") +} + +var hopByHopHeaders = map[string]bool{ + "connection": true, + "keep-alive": true, + "proxy-authenticate": true, + "proxy-authorization": true, + "te": true, + "trailer": true, + "transfer-encoding": true, + "upgrade": true, +} + +func isHopByHop(header string) bool { + return hopByHopHeaders[strings.ToLower(header)] +} + +func joinPath(base, extra string) string { + base = strings.TrimRight(base, "/") + extra = strings.TrimLeft(extra, "/") + if base == "" { + return "/" + extra + } + if extra == "" { + return base + } + return base + "/" + extra +} diff --git a/internal/handler/replay_test.go b/internal/handler/replay_test.go new file mode 100644 index 00000000..93e13112 --- /dev/null +++ b/internal/handler/replay_test.go @@ -0,0 +1,236 @@ +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) + } + } +} diff --git a/internal/handler/sse.go b/internal/handler/sse.go new file mode 100644 index 00000000..3dc3081d --- /dev/null +++ b/internal/handler/sse.go @@ -0,0 +1,125 @@ +package handler + +import ( + "encoding/json" + "fmt" + "net/http" + "sync" + + "github.com/elfoundation/hatch/internal/store" +) + +// sseHub manages SSE subscribers per endpoint. +type sseHub struct { + mu sync.RWMutex + subs map[string]map[chan []byte]struct{} +} + +var hub = &sseHub{ + subs: make(map[string]map[chan []byte]struct{}), +} + +func (h *sseHub) subscribe(endpointID string) chan []byte { + ch := make(chan []byte, 64) + h.mu.Lock() + defer h.mu.Unlock() + if h.subs[endpointID] == nil { + h.subs[endpointID] = make(map[chan []byte]struct{}) + } + h.subs[endpointID][ch] = struct{}{} + return ch +} + +func (h *sseHub) unsubscribe(endpointID string, ch chan []byte) { + h.mu.Lock() + defer h.mu.Unlock() + delete(h.subs[endpointID], ch) + if len(h.subs[endpointID]) == 0 { + delete(h.subs, endpointID) + } +} + +// sseRequest is the JSON payload sent over SSE. +// It uses string for Body to avoid base64 encoding. +type sseRequest struct { + ID string `json:"id"` + EndpointID string `json:"endpoint_id"` + Method string `json:"method"` + Path string `json:"path"` + Headers string `json:"headers"` + Query string `json:"query"` + Body string `json:"body"` + CreatedAt string `json:"created_at"` +} + +func (h *sseHub) broadcast(endpointID string, req *store.Request) { + h.mu.RLock() + defer h.mu.RUnlock() + subs := h.subs[endpointID] + if subs == nil { + return + } + sseReq := sseRequest{ + ID: req.ID, + EndpointID: req.EndpointID, + Method: req.Method, + Path: req.Path, + Headers: req.Headers, + Query: req.Query, + Body: string(req.Body), + CreatedAt: req.CreatedAt, + } + data, err := json.Marshal(sseReq) + if err != nil { + return + } + for ch := range subs { + select { + case ch <- data: + default: + } + } +} + +// broadcastRequest publishes req to SSE subscribers for its endpoint. +func broadcastRequest(endpointID string, req *store.Request) { + hub.broadcast(endpointID, req) +} + +// HandleSSE serves an SSE stream for GET /e/{endpointID}/events. +func HandleSSE(repo store.Repository) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + endpointID := r.PathValue("endpointID") + if endpointID == "" { + http.Error(w, "missing endpoint ID", http.StatusBadRequest) + return + } + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.WriteHeader(http.StatusOK) + flusher.Flush() + + ch := hub.subscribe(endpointID) + defer hub.unsubscribe(endpointID, ch) + + ctx := r.Context() + for { + select { + case <-ctx.Done(): + return + case data, ok := <-ch: + if !ok { + return + } + fmt.Fprintf(w, "data: %s\n\n", data) + flusher.Flush() + } + } + } +} diff --git a/internal/store/db.go b/internal/store/db.go new file mode 100644 index 00000000..9d08e6ba --- /dev/null +++ b/internal/store/db.go @@ -0,0 +1,35 @@ +package store + +import ( + "database/sql" + _ "embed" + "fmt" + "os" + "path/filepath" + _ "modernc.org/sqlite" +) + +//go:embed schema.sql +var schemaSQL string + +func Open(dbPath string) (Repository, error) { + if dbPath == "" { dbPath = filepath.Join("data", "hatch.db") } + dir := filepath.Dir(dbPath) + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("store: create db dir %s: %w", dir, err) + } + conn, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL&_busy_timeout=5000") + if err != nil { return nil, fmt.Errorf("store: open %s: %w", dbPath, err) } + conn.SetMaxOpenConns(1) + if err := migrate(conn); err != nil { + conn.Close() + return nil, fmt.Errorf("store: migrate: %w", err) + } + return NewSQLiteRepo(conn) +} + +func migrate(db *sql.DB) error { + _, err := db.Exec(schemaSQL) + if err != nil { return fmt.Errorf("store/migrate: %w", err) } + return nil +} diff --git a/internal/store/doc.go b/internal/store/doc.go new file mode 100644 index 00000000..33ccf6b8 --- /dev/null +++ b/internal/store/doc.go @@ -0,0 +1,2 @@ +// Package store provides the SQLite-backed persistence layer for Hatch. +package store diff --git a/internal/store/models.go b/internal/store/models.go new file mode 100644 index 00000000..6f8ef2e6 --- /dev/null +++ b/internal/store/models.go @@ -0,0 +1,42 @@ +package store + +import "context" + +type Endpoint struct { + ID string `json:"id"` + URL string `json:"url"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type Request struct { + ID string `json:"id"` + EndpointID string `json:"endpoint_id"` + Method string `json:"method"` + Path string `json:"path"` + Headers string `json:"headers"` + Query string `json:"query"` + Body []byte `json:"body"` + CreatedAt string `json:"created_at"` +} + +// MockConfig holds the mock response configuration for an endpoint. +type MockConfig struct { + EndpointID string `json:"endpoint_id"` + Status int `json:"status"` + Headers map[string]string `json:"headers"` + Body []byte `json:"body"` +} + +type Repository interface { + CreateEndpoint(ctx context.Context, url string) (*Endpoint, error) + GetEndpoint(ctx context.Context, id string) (*Endpoint, error) + AppendRequest(ctx context.Context, endpointID string, req *Request) error + GetRequest(ctx context.Context, id string) (*Request, error) + ListRequests(ctx context.Context, endpointID string, limit int) ([]*Request, error) + GetMock(ctx context.Context, endpointID string) (*MockConfig, error) + SetMock(ctx context.Context, mock *MockConfig) error + Close() error +} + +var _ Repository = (*sqliteRepo)(nil) diff --git a/internal/store/schema.sql b/internal/store/schema.sql new file mode 100644 index 00000000..026b257e --- /dev/null +++ b/internal/store/schema.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS endpoints ( + id TEXT NOT NULL PRIMARY KEY, + url TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + mock_status INTEGER, + mock_headers TEXT, + mock_body BLOB +); +CREATE TABLE IF NOT EXISTS requests ( + id TEXT NOT NULL PRIMARY KEY, + endpoint_id TEXT NOT NULL REFERENCES endpoints(id), + method TEXT NOT NULL, + path TEXT NOT NULL, + headers TEXT NOT NULL DEFAULT '{}', + query TEXT NOT NULL DEFAULT '', + body BLOB, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_requests_endpoint_id ON requests(endpoint_id); +CREATE INDEX IF NOT EXISTS idx_requests_created_at ON requests(created_at); diff --git a/internal/store/sqlite_repo.go b/internal/store/sqlite_repo.go new file mode 100644 index 00000000..f7371354 --- /dev/null +++ b/internal/store/sqlite_repo.go @@ -0,0 +1,121 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" +) + +type sqliteRepo struct{ db *sql.DB } + +func NewSQLiteRepo(db *sql.DB) (Repository, error) { + if db == nil { + return nil, fmt.Errorf("store: nil db") + } + return &sqliteRepo{db: db}, nil +} + +func (r *sqliteRepo) CreateEndpoint(ctx context.Context, url string) (*Endpoint, error) { + now := utcNow() + e := &Endpoint{ID: url, URL: url, CreatedAt: now, UpdatedAt: now} + _, err := r.db.ExecContext(ctx, `INSERT INTO endpoints (id, url, created_at, updated_at) VALUES (?, ?, ?, ?)`, e.ID, e.URL, e.CreatedAt, e.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("store: create endpoint: %w", err) + } + return e, nil +} + +func (r *sqliteRepo) GetEndpoint(ctx context.Context, id string) (*Endpoint, error) { + row := r.db.QueryRowContext(ctx, `SELECT id, url, created_at, updated_at FROM endpoints WHERE id = ?`, id) + e := &Endpoint{} + if err := row.Scan(&e.ID, &e.URL, &e.CreatedAt, &e.UpdatedAt); err != nil { + return nil, fmt.Errorf("store: get endpoint: %w", err) + } + return e, nil +} + +func (r *sqliteRepo) AppendRequest(ctx context.Context, endpointID string, req *Request) error { + req.ID = uuid.NewString() + req.EndpointID = endpointID + req.CreatedAt = utcNow() + _, err := r.db.ExecContext(ctx, `INSERT INTO requests (id, endpoint_id, method, path, headers, query, body, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + req.ID, req.EndpointID, req.Method, req.Path, req.Headers, req.Query, req.Body, req.CreatedAt) + if err != nil { + return fmt.Errorf("store: append request: %w", err) + } + return nil +} + +func (r *sqliteRepo) GetRequest(ctx context.Context, id string) (*Request, error) { + row := r.db.QueryRowContext(ctx, `SELECT id, endpoint_id, method, path, headers, query, body, created_at FROM requests WHERE id = ?`, id) + req := &Request{} + if err := row.Scan(&req.ID, &req.EndpointID, &req.Method, &req.Path, &req.Headers, &req.Query, &req.Body, &req.CreatedAt); err != nil { + return nil, fmt.Errorf("store: get request %s: %w", id, err) + } + return req, nil +} + +func (r *sqliteRepo) ListRequests(ctx context.Context, endpointID string, limit int) ([]*Request, error) { + if limit <= 0 { + limit = 50 + } + rows, err := r.db.QueryContext(ctx, `SELECT id, endpoint_id, method, path, headers, query, body, created_at FROM requests WHERE endpoint_id = ? ORDER BY created_at DESC LIMIT ?`, endpointID, limit) + if err != nil { + return nil, fmt.Errorf("store: list requests: %w", err) + } + defer rows.Close() + var out []*Request + for rows.Next() { + var req Request + if err := rows.Scan(&req.ID, &req.EndpointID, &req.Method, &req.Path, &req.Headers, &req.Query, &req.Body, &req.CreatedAt); err != nil { + return nil, fmt.Errorf("store: scan request: %w", err) + } + out = append(out, &req) + } + return out, rows.Err() +} + +func (r *sqliteRepo) GetMock(ctx context.Context, endpointID string) (*MockConfig, error) { + row := r.db.QueryRowContext(ctx, `SELECT mock_status, mock_headers, mock_body FROM endpoints WHERE id = ?`, endpointID) + var status sql.NullInt64 + var headersJSON sql.NullString + var body []byte + if err := row.Scan(&status, &headersJSON, &body); err != nil { + return nil, fmt.Errorf("store: get mock: %w", err) + } + if !status.Valid { + return nil, fmt.Errorf("store: no mock configured for %s", endpointID) + } + m := &MockConfig{ + EndpointID: endpointID, + Status: int(status.Int64), + Body: body, + } + if headersJSON.Valid && headersJSON.String != "" { + json.Unmarshal([]byte(headersJSON.String), &m.Headers) + } + return m, nil +} + +func (r *sqliteRepo) SetMock(ctx context.Context, mock *MockConfig) error { + headersJSON, err := json.Marshal(mock.Headers) + if err != nil { + return fmt.Errorf("store: marshal mock headers: %w", err) + } + _, err = r.db.ExecContext(ctx, + `UPDATE endpoints SET mock_status = ?, mock_headers = ?, mock_body = ? WHERE id = ?`, + mock.Status, string(headersJSON), mock.Body, mock.EndpointID, + ) + if err != nil { + return fmt.Errorf("store: set mock: %w", err) + } + return nil +} + +func (r *sqliteRepo) Close() error { return r.db.Close() } + +func utcNow() string { return time.Now().UTC().Format("2006-01-02T15:04:05.000Z07:00") } diff --git a/internal/store/sqlite_repo_test.go b/internal/store/sqlite_repo_test.go new file mode 100644 index 00000000..7e0a6a33 --- /dev/null +++ b/internal/store/sqlite_repo_test.go @@ -0,0 +1,234 @@ +package store + +import ( + "context" + "database/sql" + "testing" + "time" +) + +func openTestRepo(t *testing.T) Repository { + t.Helper() + db, err := sql.Open("sqlite", ":memory:?_journal_mode=WAL&_foreign_keys=on") + if err != nil { + t.Fatalf("open in-memory sqlite: %v", err) + } + db.SetMaxOpenConns(1) + if err := migrate(db); err != nil { + db.Close() + t.Fatalf("migrate: %v", err) + } + repo, err := NewSQLiteRepo(db) + if err != nil { + db.Close() + t.Fatalf("new sqlite repo: %v", err) + } + t.Cleanup(func() { repo.Close() }) + return repo +} + +func TestCreateAndGetEndpoint(t *testing.T) { + repo := openTestRepo(t) + e, err := repo.CreateEndpoint(context.Background(), "test-one") + if err != nil { + t.Fatalf("CreateEndpoint: %v", err) + } + if e.ID == "" { + t.Error("expected non-empty ID") + } + if e.URL != "test-one" { + t.Errorf("url: got %q", e.URL) + } + if e.CreatedAt == "" || e.UpdatedAt == "" { + t.Error("expected timestamps") + } + got, err := repo.GetEndpoint(context.Background(), e.ID) + if err != nil { + t.Fatalf("GetEndpoint: %v", err) + } + if got.ID != e.ID || got.URL != e.URL { + t.Errorf("got %+v", got) + } +} + +func TestGetEndpointNotFound(t *testing.T) { + repo := openTestRepo(t) + _, err := repo.GetEndpoint(context.Background(), "nonexistent") + if err == nil { + t.Fatal("expected error") + } +} + +func TestCreateEndpointDuplicateURL(t *testing.T) { + repo := openTestRepo(t) + ctx := context.Background() + if _, err := repo.CreateEndpoint(ctx, "dup"); err != nil { + t.Fatal(err) + } + if _, err := repo.CreateEndpoint(ctx, "dup"); err == nil { + t.Fatal("expected UNIQUE error") + } +} + +func TestAppendAndListRequests(t *testing.T) { + repo := openTestRepo(t) + ctx := context.Background() + e, err := repo.CreateEndpoint(ctx, "reqs-test") + if err != nil { + t.Fatal(err) + } + r1 := &Request{Method: "POST", Path: "/webhook", Headers: `{"Content-Type":"application/json"}`, Query: "foo=bar", Body: []byte(`{"hello":"world"}`)} + if err := repo.AppendRequest(ctx, e.ID, r1); err != nil { + t.Fatal(err) + } + time.Sleep(time.Millisecond) + r2 := &Request{Method: "GET", Path: "/webhook", Headers: `{}`} + if err := repo.AppendRequest(ctx, e.ID, r2); err != nil { + t.Fatal(err) + } + reqs, err := repo.ListRequests(ctx, e.ID, 10) + if err != nil { + t.Fatal(err) + } + if len(reqs) != 2 { + t.Fatalf("got %d requests", len(reqs)) + } + if reqs[0].Method != "GET" { + t.Errorf("first: got %s", reqs[0].Method) + } + if reqs[1].Method != "POST" { + t.Errorf("second: got %s", reqs[1].Method) + } +} + +func TestGetRequest(t *testing.T) { + repo := openTestRepo(t) + ctx := context.Background() + e, _ := repo.CreateEndpoint(ctx, "getreq-test") + r := &Request{Method: "POST", Path: "/webhook", Headers: `{"X-Api-Key":"secret"}`, Query: "a=1", Body: []byte(`{"msg":"hi"}`)} + if err := repo.AppendRequest(ctx, e.ID, r); err != nil { + t.Fatal(err) + } + got, err := repo.GetRequest(ctx, r.ID) + if err != nil { + t.Fatalf("GetRequest: %v", err) + } + if got.ID != r.ID { + t.Errorf("id: got %q want %q", got.ID, r.ID) + } + if got.Method != "POST" { + t.Errorf("method: got %q", got.Method) + } + if string(got.Body) != `{"msg":"hi"}` { + t.Errorf("body: got %q", string(got.Body)) + } +} + +func TestGetRequestNotFound(t *testing.T) { + repo := openTestRepo(t) + _, err := repo.GetRequest(context.Background(), "nonexistent") + if err == nil { + t.Fatal("expected error for nonexistent request") + } +} + +func TestSetAndGetMock(t *testing.T) { + repo := openTestRepo(t) + ctx := context.Background() + e, err := repo.CreateEndpoint(ctx, "mock-test") + if err != nil { + t.Fatal(err) + } + mock := &MockConfig{ + EndpointID: e.ID, + Status: 201, + Headers: map[string]string{"Content-Type": "application/json"}, + Body: []byte(`{"mocked": true}`), + } + if err := repo.SetMock(ctx, mock); err != nil { + t.Fatal(err) + } + got, err := repo.GetMock(ctx, e.ID) + if err != nil { + t.Fatal(err) + } + if got.Status != 201 { + t.Errorf("status: got %d", got.Status) + } + if string(got.Body) != `{"mocked": true}` { + t.Errorf("body: got %q", string(got.Body)) + } +} + +func TestGetMockNotFound(t *testing.T) { + repo := openTestRepo(t) + _, err := repo.GetMock(context.Background(), "no-mock") + if err == nil { + t.Fatal("expected error") + } +} + +func TestListRequestsLimit(t *testing.T) { + repo := openTestRepo(t) + ctx := context.Background() + e, _ := repo.CreateEndpoint(ctx, "limit-test") + for i := 0; i < 5; i++ { + repo.AppendRequest(ctx, e.ID, &Request{Method: "GET", Path: "/"}) + } + reqs, err := repo.ListRequests(ctx, e.ID, 3) + if err != nil { + t.Fatal(err) + } + if len(reqs) != 3 { + t.Fatalf("got %d", len(reqs)) + } +} + +func TestListRequestsEmpty(t *testing.T) { + repo := openTestRepo(t) + ctx := context.Background() + e, _ := repo.CreateEndpoint(ctx, "empty-test") + reqs, _ := repo.ListRequests(ctx, e.ID, 10) + if len(reqs) != 0 { + t.Errorf("got %d", len(reqs)) + } +} + +func TestAppendRequestBodyBinary(t *testing.T) { + repo := openTestRepo(t) + ctx := context.Background() + e, _ := repo.CreateEndpoint(ctx, "binary-test") + body := []byte{0x00, 0x01, 0x02, 0xFF} + if err := repo.AppendRequest(ctx, e.ID, &Request{Method: "POST", Path: "/binary", Body: body}); err != nil { + t.Fatal(err) + } + reqs, _ := repo.ListRequests(ctx, e.ID, 1) + if len(reqs) != 1 { + t.Fatal("expected 1 request") + } + if len(reqs[0].Body) != 4 { + t.Errorf("body len: %d", len(reqs[0].Body)) + } +} + +func TestMigrateIdempotent(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:?_journal_mode=WAL&_foreign_keys=on") + if err != nil { + t.Fatal(err) + } + defer db.Close() + db.SetMaxOpenConns(1) + if err := migrate(db); err != nil { + t.Fatal(err) + } + if err := migrate(db); err != nil { + t.Fatal(err) + } + var name string + if err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name='endpoints'").Scan(&name); err != nil { + t.Fatalf("endpoints table: %v", err) + } + if err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name='requests'").Scan(&name); err != nil { + t.Fatalf("requests table: %v", err) + } +}