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:
parent
7668661f74
commit
027e9a4a49
3
.gitignore
vendored
3
.gitignore
vendored
@ -19,4 +19,5 @@ go.work
|
|||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
.DS_Store
|
.DS_Store
|
||||||
hatch
|
/hatch
|
||||||
|
hatch.exe
|
||||||
|
|||||||
@ -1,11 +1,15 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/elfoundation/hatch/internal/handler"
|
"github.com/elfoundation/hatch/internal/handler"
|
||||||
"github.com/elfoundation/hatch/internal/store"
|
"github.com/elfoundation/hatch/internal/store"
|
||||||
@ -74,3 +78,379 @@ func TestHealthzSmoke(t *testing.T) {
|
|||||||
t.Fatalf("expected body 'ok', got %q", 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, "<!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)
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user