Add E2E smoke test — one-command DoD gate (ELF-23 Task 8)

TestSmokeE2E starts a real server and exercises the full product flow:
- Phase 1: Health check (liveness probe)
- Phase 2: Capture all 5 HTTP methods, verify stored
- Phase 3: Inspect page renders HTML with request cards, replay UI, SSE script
- Phase 4: SSE stream receives live events on capture
- Phase 5: Mock configuration and response (set mock → capture returns mock)
- Phase 6: Replay a captured request to a sink server
- Phase 7: Edge cases (query params, binary body, SSRF protection)

The one-command gate: go test ./cmd/hatch -run TestSmokeE2E -v

Also fix .gitignore: the bare 'hatch' entry was matching cmd/hatch/
directory, preventing test files from being tracked. Narrowed to /hatch
(root binary only) and hatch.exe.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
SoftwareEngineer 2026-06-22 19:22:56 +02:00
parent 7668661f74
commit 027e9a4a49
2 changed files with 382 additions and 1 deletions

3
.gitignore vendored
View File

@ -19,4 +19,5 @@ go.work
*.swo
*~
.DS_Store
hatch
/hatch
hatch.exe

View File

@ -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, "<!DOCTYPE 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)
}