diff --git a/.gitignore b/.gitignore index ec64e4fd..3747f69c 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,5 @@ go.work *.swo *~ .DS_Store -hatch +/hatch +hatch.exe diff --git a/cmd/hatch/main_test.go b/cmd/hatch/main_test.go index a9eaf49b..94bcbb22 100644 --- a/cmd/hatch/main_test.go +++ b/cmd/hatch/main_test.go @@ -1,11 +1,15 @@ 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" @@ -74,3 +78,379 @@ func TestHealthzSmoke(t *testing.T) { 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) +}