← 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
M internal/generator/generator.goA internal/generator/templates/captured_test.go.tmplA internal/websniff/fixtures.goA internal/websniff/fixtures_test.go
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 →