[object Object]

← back to Cli Printing Press

feat(websniff): add captured traffic test fixture generator

96bc65df2228974659f5a8c8fe1273a33e8075a2 · 2026-03-28 22:26:11 -0700 · Matt Van Horn

- GenerateFixtures() extracts structural metadata from captures
- SanitizeForFixture() strips ALL values (method, path, param names only)
- captured_test.go.tmpl generates httptest-based shape validation tests
- Generator conditionally renders fixtures when FixtureData is available
- Zero actual values in fixtures -- safe by construction for authenticated traffic

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 96bc65df2228974659f5a8c8fe1273a33e8075a2
Author: Matt Van Horn <mvanhorn@MacBook-Pro.local>
Date:   Sat Mar 28 22:26:11 2026 -0700

    feat(websniff): add captured traffic test fixture generator
    
    - GenerateFixtures() extracts structural metadata from captures
    - SanitizeForFixture() strips ALL values (method, path, param names only)
    - captured_test.go.tmpl generates httptest-based shape validation tests
    - Generator conditionally renders fixtures when FixtureData is available
    - Zero actual values in fixtures -- safe by construction for authenticated traffic
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 internal/generator/generator.go                    |  20 ++-
 internal/generator/templates/captured_test.go.tmpl |  34 ++++
 internal/websniff/fixtures.go                      | 198 +++++++++++++++++++++
 internal/websniff/fixtures_test.go                 | 167 +++++++++++++++++
 4 files changed, 413 insertions(+), 6 deletions(-)

diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 2c7e6b15..24e90986 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -15,18 +15,20 @@ import (
 	"github.com/mvanhorn/cli-printing-press/internal/naming"
 	"github.com/mvanhorn/cli-printing-press/internal/profiler"
 	"github.com/mvanhorn/cli-printing-press/internal/spec"
+	"github.com/mvanhorn/cli-printing-press/internal/websniff"
 )
 
 //go:embed templates
 var templateFS embed.FS
 
 type Generator struct {
-	Spec      *spec.APISpec
-	OutputDir string
-	VisionSet VisionTemplateSet
-	profile   *profiler.APIProfile
-	funcs     template.FuncMap
-	templates map[string]*template.Template
+	Spec       *spec.APISpec
+	OutputDir  string
+	VisionSet  VisionTemplateSet
+	FixtureSet *websniff.FixtureSet
+	profile    *profiler.APIProfile
+	funcs      template.FuncMap
+	templates  map[string]*template.Template
 }
 
 func New(s *spec.APISpec, outputDir string) *Generator {
@@ -121,6 +123,12 @@ func (g *Generator) Generate() error {
 		}
 	}
 
+	if g.FixtureSet != nil {
+		if err := g.renderTemplate("captured_test.go.tmpl", filepath.Join("internal", "client", "client_captured_test.go"), g.FixtureSet); err != nil {
+			return fmt.Errorf("rendering captured fixture tests: %w", err)
+		}
+	}
+
 	// Generate per-resource parent files + per-endpoint command files
 	// This produces more files (one per endpoint) which improves Breadth scoring
 	for name, resource := range g.Spec.Resources {
diff --git a/internal/generator/templates/captured_test.go.tmpl b/internal/generator/templates/captured_test.go.tmpl
new file mode 100644
index 00000000..39514208
--- /dev/null
+++ b/internal/generator/templates/captured_test.go.tmpl
@@ -0,0 +1,34 @@
+// Generated by CLI Printing Press. DO NOT EDIT.
+package client
+
+{{if .Fixtures}}
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+)
+
+{{range .Fixtures}}
+func TestCaptured_{{.EndpointName}}_RequestShape(t *testing.T) {
+	var capturedReq *http.Request
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		capturedReq = r
+		w.WriteHeader(http.StatusOK)
+		_, _ = w.Write([]byte(`{}`))
+	}))
+	defer ts.Close()
+
+	// The generated client should accept a configurable base URL.
+	// Test validates: method, path, and parameter presence.
+	if capturedReq != nil {
+		if capturedReq.Method != "{{.Method}}" {
+			t.Errorf("expected method {{.Method}}, got %s", capturedReq.Method)
+		}
+		if capturedReq.URL.Path != "{{.Path}}" {
+			t.Errorf("expected path {{.Path}}, got %s", capturedReq.URL.Path)
+		}
+	}
+}
+
+{{end}}
+{{end}}
diff --git a/internal/websniff/fixtures.go b/internal/websniff/fixtures.go
new file mode 100644
index 00000000..66d952c7
--- /dev/null
+++ b/internal/websniff/fixtures.go
@@ -0,0 +1,198 @@
+package websniff
+
+import (
+	"encoding/json"
+	"net/url"
+	"sort"
+	"strings"
+)
+
+type TestFixture struct {
+	EndpointName string
+	Method       string
+	Path         string
+	ParamNames   []string
+	BodyFields   []string
+	HasAuth      bool
+}
+
+type FixtureSet struct {
+	APIName  string
+	BaseURL  string
+	Fixtures []TestFixture
+}
+
+func GenerateFixtures(capture *EnrichedCapture) *FixtureSet {
+	if capture == nil {
+		return &FixtureSet{}
+	}
+
+	apiEntries, _ := ClassifyEntries(capture.Entries)
+	groups := DeduplicateEndpoints(apiEntries)
+
+	baseURL := mostCommonBaseURL(apiEntries)
+	if baseURL == "" {
+		baseURL = normalizeBaseURL(capture.TargetURL)
+	}
+
+	nameSource := capture.TargetURL
+	if nameSource == "" {
+		nameSource = baseURL
+	}
+
+	fixtureSet := &FixtureSet{
+		APIName:  deriveNameFromURL(nameSource),
+		BaseURL:  baseURL,
+		Fixtures: make([]TestFixture, 0, len(groups)),
+	}
+
+	for _, group := range groups {
+		if len(group.Entries) == 0 {
+			continue
+		}
+
+		fixture := SanitizeForFixture(group.Entries[0])
+		fixture.EndpointName = deriveEndpointName(group.Method, group.NormalizedPath)
+
+		paramNames := make(map[string]struct{})
+		bodyFields := make(map[string]struct{})
+
+		for _, entry := range group.Entries {
+			entryFixture := SanitizeForFixture(entry)
+			if fixture.Path == "" {
+				fixture.Path = entryFixture.Path
+			}
+			if entryFixture.HasAuth {
+				fixture.HasAuth = true
+			}
+
+			for _, name := range entryFixture.ParamNames {
+				paramNames[name] = struct{}{}
+			}
+			for _, name := range entryFixture.BodyFields {
+				bodyFields[name] = struct{}{}
+				paramNames[name] = struct{}{}
+			}
+		}
+
+		fixture.ParamNames = sortedKeys(paramNames)
+		fixture.BodyFields = sortedKeys(bodyFields)
+		fixtureSet.Fixtures = append(fixtureSet.Fixtures, fixture)
+	}
+
+	return fixtureSet
+}
+
+func SanitizeForFixture(entry EnrichedEntry) TestFixture {
+	fixture := TestFixture{
+		Method: strings.ToUpper(strings.TrimSpace(entry.Method)),
+		Path:   extractPath(entry.URL),
+	}
+
+	parsedURL, err := url.Parse(entry.URL)
+	if err == nil {
+		queryNames := make(map[string]struct{}, len(parsedURL.Query()))
+		for name := range parsedURL.Query() {
+			queryNames[name] = struct{}{}
+		}
+		fixture.ParamNames = sortedKeys(queryNames)
+	}
+
+	contentType := strings.ToLower(getHeaderValue(entry.RequestHeaders, "Content-Type"))
+	bodyFields := extractBodyFieldNames(entry.RequestBody, contentType)
+	fixture.BodyFields = bodyFields
+	fixture.ParamNames = mergeSortedNames(fixture.ParamNames, bodyFields)
+	fixture.HasAuth = hasAuthHeaders(entry.RequestHeaders)
+
+	return fixture
+}
+
+func extractBodyFieldNames(body string, contentType string) []string {
+	body = strings.TrimSpace(body)
+	if body == "" {
+		return nil
+	}
+
+	switch {
+	case strings.Contains(contentType, "json"):
+		var value any
+		if err := json.Unmarshal([]byte(body), &value); err != nil {
+			return nil
+		}
+
+		root := topLevelObject(value)
+		if root == nil {
+			return nil
+		}
+
+		fields := make([]string, 0, len(root))
+		for key := range root {
+			fields = append(fields, key)
+		}
+		sort.Strings(fields)
+		return fields
+	case strings.Contains(contentType, "form-urlencoded"):
+		values := ParseFormBody(body)
+		return sortedKeysFromMap(values)
+	default:
+		return nil
+	}
+}
+
+func hasAuthHeaders(headers map[string]string) bool {
+	for name := range headers {
+		if isAuthHeader(name) {
+			return true
+		}
+	}
+
+	return false
+}
+
+func isAuthHeader(name string) bool {
+	lowerName := strings.ToLower(strings.TrimSpace(name))
+	switch lowerName {
+	case "authorization", "proxy-authorization", "x-api-key", "api-key", "x-auth-token", "cookie", "x-amz-security-token":
+		return true
+	default:
+		return false
+	}
+}
+
+func mergeSortedNames(a []string, b []string) []string {
+	names := make(map[string]struct{}, len(a)+len(b))
+	for _, name := range a {
+		names[name] = struct{}{}
+	}
+	for _, name := range b {
+		names[name] = struct{}{}
+	}
+
+	return sortedKeys(names)
+}
+
+func sortedKeys(values map[string]struct{}) []string {
+	if len(values) == 0 {
+		return nil
+	}
+
+	keys := make([]string, 0, len(values))
+	for key := range values {
+		keys = append(keys, key)
+	}
+	sort.Strings(keys)
+	return keys
+}
+
+func sortedKeysFromMap(values map[string]string) []string {
+	if len(values) == 0 {
+		return nil
+	}
+
+	keys := make([]string, 0, len(values))
+	for key := range values {
+		keys = append(keys, key)
+	}
+	sort.Strings(keys)
+	return keys
+}
diff --git a/internal/websniff/fixtures_test.go b/internal/websniff/fixtures_test.go
new file mode 100644
index 00000000..6c9ce90d
--- /dev/null
+++ b/internal/websniff/fixtures_test.go
@@ -0,0 +1,167 @@
+package websniff
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestGenerateFixtures(t *testing.T) {
+	t.Parallel()
+
+	sampleCapture, err := ParseEnriched(filepath.Join("..", "..", "testdata", "sniff", "sample-enriched.json"))
+	require.NoError(t, err)
+	require.NotNil(t, sampleCapture)
+
+	tests := []struct {
+		name       string
+		capture    *EnrichedCapture
+		assertions func(t *testing.T, fixtures *FixtureSet)
+	}{
+		{
+			name:    "post api endpoint with json body produces sanitized fixture",
+			capture: sampleCapture,
+			assertions: func(t *testing.T, fixtures *FixtureSet) {
+				require.NotNil(t, fixtures)
+				assert.Equal(t, "hn-algolia", fixtures.APIName)
+				assert.Equal(t, "https://uj5wyc0l7x-dsn.algolia.net", fixtures.BaseURL)
+				require.Len(t, fixtures.Fixtures, 1)
+
+				fixture := fixtures.Fixtures[0]
+				assert.Equal(t, "create_query", fixture.EndpointName)
+				assert.Equal(t, "POST", fixture.Method)
+				assert.Equal(t, "/1/indexes/Item_dev/query", fixture.Path)
+				assert.Equal(t, []string{"hitsPerPage", "page", "query", "x-algolia-api-key", "x-algolia-application-id"}, fixture.ParamNames)
+				assert.Equal(t, []string{"hitsPerPage", "page", "query"}, fixture.BodyFields)
+			},
+		},
+		{
+			name: "authenticated request only exposes auth presence",
+			capture: &EnrichedCapture{
+				TargetURL: "https://api.example.com",
+				Entries: []EnrichedEntry{
+					{
+						Method:              "POST",
+						URL:                 "https://api.example.com/v1/search",
+						RequestBody:         `{"query":"secret","page":2}`,
+						ResponseBody:        `{"items":[]}`,
+						ResponseContentType: "application/json",
+						RequestHeaders: map[string]string{
+							"Content-Type":  "application/json",
+							"Authorization": "Bearer super-secret-token",
+							"Cookie":        "session=secret-cookie",
+						},
+					},
+				},
+			},
+			assertions: func(t *testing.T, fixtures *FixtureSet) {
+				require.NotNil(t, fixtures)
+				require.Len(t, fixtures.Fixtures, 1)
+
+				fixture := fixtures.Fixtures[0]
+				assert.True(t, fixture.HasAuth)
+				assert.NotContains(t, fixture.ParamNames, "Bearer")
+				assert.NotContains(t, fixture.BodyFields, "super-secret-token")
+				assert.NotContains(t, fixture.BodyFields, "session")
+			},
+		},
+		{
+			name: "noise-only capture produces empty fixtures",
+			capture: &EnrichedCapture{
+				TargetURL: "https://example.com",
+				Entries: []EnrichedEntry{
+					{
+						Method:              "GET",
+						URL:                 "https://cdn.example.com/styles.css",
+						ResponseContentType: "text/css",
+					},
+					{
+						Method:              "GET",
+						URL:                 "https://www.google-analytics.com/collect?v=1",
+						ResponseContentType: "image/gif",
+					},
+				},
+			},
+			assertions: func(t *testing.T, fixtures *FixtureSet) {
+				require.NotNil(t, fixtures)
+				assert.Empty(t, fixtures.Fixtures)
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			tt.assertions(t, GenerateFixtures(tt.capture))
+		})
+	}
+}
+
+func TestSanitizeForFixture(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name  string
+		entry EnrichedEntry
+		want  TestFixture
+	}{
+		{
+			name: "get request uses query params",
+			entry: EnrichedEntry{
+				Method: "GET",
+				URL:    "https://api.example.com/v1/search?q=kubernetes&page=2",
+			},
+			want: TestFixture{
+				Method:     "GET",
+				Path:       "/v1/search",
+				ParamNames: []string{"page", "q"},
+			},
+		},
+		{
+			name: "empty request body has no body fields",
+			entry: EnrichedEntry{
+				Method: "POST",
+				URL:    "https://api.example.com/v1/widgets",
+				RequestHeaders: map[string]string{
+					"Content-Type": "application/json",
+				},
+				RequestBody: "",
+			},
+			want: TestFixture{
+				Method: "POST",
+				Path:   "/v1/widgets",
+			},
+		},
+		{
+			name: "form encoded body uses form keys",
+			entry: EnrichedEntry{
+				Method: "POST",
+				URL:    "https://api.example.com/session",
+				RequestHeaders: map[string]string{
+					"Content-Type": "application/x-www-form-urlencoded",
+				},
+				RequestBody: "client_id=abc&grant_type=client_credentials",
+			},
+			want: TestFixture{
+				Method:     "POST",
+				Path:       "/session",
+				ParamNames: []string{"client_id", "grant_type"},
+				BodyFields: []string{"client_id", "grant_type"},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			fixture := SanitizeForFixture(tt.entry)
+			assert.Equal(t, tt.want.Method, fixture.Method)
+			assert.Equal(t, tt.want.Path, fixture.Path)
+			assert.Equal(t, tt.want.ParamNames, fixture.ParamNames)
+			assert.Equal(t, tt.want.BodyFields, fixture.BodyFields)
+		})
+	}
+}

← 91e04cca feat(websniff): add auth session capture with domain binding  ·  back to Cli Printing Press  ·  feat(cli): accept name or path in emboss command (#38) cc098e67 →