From f669d056c9cc7bc9e0f1859ef60ca02d51a56cf0 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 17 Sep 2026 12:21:34 +0200 Subject: [PATCH] feat(02-01): lock the one-route tide record and replay contract - Reject unknown YAML fields, empty names, duplicate steps and unsafe sidecars - Treat JSON key order as insignificant and fail missing keys and token types at $.path - Bound request bodies and refuse oversized or malformed input before writing a fixture Co-authored-by: Cursor --- cmd/summer/parity_test.go | 25 ++++++++ tide/record.go | 3 + tide/roundtrip_test.go | 127 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+) diff --git a/cmd/summer/parity_test.go b/cmd/summer/parity_test.go index a9ce9f5..ae3d27a 100644 --- a/cmd/summer/parity_test.go +++ b/cmd/summer/parity_test.go @@ -74,6 +74,31 @@ func TestParityCommands(t *testing.T) { if !strings.Contains(err.Error(), "1") { t.Fatalf("byte mismatch missing offset: %v", err) } + + badSpec := filepath.Join(outDir, "bad.yaml") + if err := os.WriteFile(badSpec, []byte("version: [\n"), 0o644); err != nil { + t.Fatal(err) + } + leftover := filepath.Join(outDir, "should-not-exist.yaml") + err = runParity("parity:record", "--spec", badSpec, "--target", origJSON.URL, "--output", leftover) + if err == nil { + t.Fatal("malformed YAML must fail") + } + if _, statErr := os.Stat(leftover); !os.IsNotExist(statErr) { + t.Fatalf("malformed spec committed a fixture: %v", statErr) + } + + keyOrder := httptest.NewServer(jsonHandler(`{"a":"x","z":1}`)) + t.Cleanup(keyOrder.Close) + origKeys := httptest.NewServer(jsonHandler(`{"z":1,"a":"x"}`)) + t.Cleanup(origKeys.Close) + keyFixture := filepath.Join(outDir, "keys.yaml") + if err := runParity("parity:record", "--spec", spec, "--target", origKeys.URL, "--output", keyFixture); err != nil { + t.Fatalf("record key order: %v", err) + } + if err := runParity("parity:replay", "--fixtures", keyFixture, "--target", keyOrder.URL); err != nil { + t.Fatalf("reordered JSON keys must pass: %v", err) + } } func commandNames() []string { diff --git a/tide/record.go b/tide/record.go index 6bd9802..eb774af 100644 --- a/tide/record.go +++ b/tide/record.go @@ -57,6 +57,9 @@ func doStep(ctx context.Context, client *http.Client, target string, req Request if err != nil { return Response{}, err } + if int64(len(req.Body)) > limit { + return Response{}, fmt.Errorf("%w: request body exceeds %d bytes", errTruncated, limit) + } var body io.Reader if req.Body != "" { body = strings.NewReader(string(req.Body)) diff --git a/tide/roundtrip_test.go b/tide/roundtrip_test.go index a4417d6..9eba9ca 100644 --- a/tide/roundtrip_test.go +++ b/tide/roundtrip_test.go @@ -114,5 +114,132 @@ func TestParityRoundTrip(t *testing.T) { if !strings.Contains(msg, "1") { t.Fatalf("byte mismatch missing offset: %s", msg) } + if !strings.Contains(msg, "hello") || !strings.Contains(msg, "hallo") { + t.Fatalf("byte mismatch missing printable bytes: %s", msg) + } + }) + + t.Run("json key order ignored missing keys and types", func(t *testing.T) { + orig := jsonServer(t, `{"z":1,"a":"x"}`) + reordered := jsonServer(t, `{"a":"x","z":1}`) + missing := jsonServer(t, `{"a":"x"}`) + wrongType := jsonServer(t, `{"z":"1","a":"x"}`) + + recorded, err := RecordFlow(ctx, spec, RecordConfig{Target: orig.URL}) + if err != nil { + t.Fatal(err) + } + if _, err := ReplayFlow(ctx, recorded, ReplayConfig{Target: reordered.URL}); err != nil { + t.Fatalf("reordered keys must pass: %v", err) + } + _, err = ReplayFlow(ctx, recorded, ReplayConfig{Target: missing.URL}) + if err == nil || !strings.Contains(err.Error(), "$.z") { + t.Fatalf("missing key must fail at $.z, got %v", err) + } + _, err = ReplayFlow(ctx, recorded, ReplayConfig{Target: wrongType.URL}) + if err == nil || !strings.Contains(err.Error(), "$.z") { + t.Fatalf("number vs string must fail at $.z, got %v", err) + } + if !strings.Contains(err.Error(), "1") { + t.Fatalf("type mismatch missing values: %v", err) + } + }) + + t.Run("rejects unknown fields empty names duplicates and unsafe paths", func(t *testing.T) { + dir := t.TempDir() + writeFlow := func(name, body string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return path + } + base := "version: 1\nname: sample\nsteps:\n - id: a\n request:\n method: GET\n path: /sample\n" + if _, err := LoadFlow(writeFlow("unknown.yaml", base+"extra: true\n")); err == nil { + t.Fatal("unknown field must fail") + } + if _, err := LoadFlow(writeFlow("empty-name.yaml", "version: 1\nname: \"\"\nsteps:\n - id: a\n request:\n method: GET\n path: /sample\n")); err == nil || !strings.Contains(err.Error(), "name") { + t.Fatalf("empty name must fail, got %v", err) + } + dup := "version: 1\nname: sample\nsteps:\n - id: a\n request:\n method: GET\n path: /a\n - id: a\n request:\n method: GET\n path: /b\n" + if _, err := LoadFlow(writeFlow("dup.yaml", dup)); err == nil || !strings.Contains(err.Error(), "duplicate") { + t.Fatalf("duplicate step id must fail, got %v", err) + } + unsafe := base + " response:\n body_file: ../secret.bin\n" + if _, err := LoadFlow(writeFlow("unsafe.yaml", unsafe)); err == nil || !strings.Contains(err.Error(), "body_file") { + t.Fatalf("parent body_file must fail, got %v", err) + } + abs := base + " response:\n body_file: /tmp/secret.bin\n" + if _, err := LoadFlow(writeFlow("abs.yaml", abs)); err == nil || !strings.Contains(err.Error(), "body_file") { + t.Fatalf("absolute body_file must fail, got %v", err) + } + }) + + t.Run("oversized body fails before fixture commit", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte("hello")) + })) + t.Cleanup(srv.Close) + out := filepath.Join(t.TempDir(), "too-big.yaml") + _, err := RecordFlow(ctx, spec, RecordConfig{Target: srv.URL, MaxBody: 4}) + if err == nil || !strings.Contains(err.Error(), "exceeds") { + t.Fatalf("truncated body must fail, got %v", err) + } + if _, statErr := os.Stat(out); !os.IsNotExist(statErr) { + t.Fatalf("fixture was committed after truncation: %v", statErr) + } + }) + + t.Run("injected client is used and save leaves no temp files", func(t *testing.T) { + srv := jsonServer(t, `{"ok":true}`) + trip := &countTransport{rt: srv.Client().Transport} + client := &http.Client{Transport: trip} + recorded, err := RecordFlow(ctx, spec, RecordConfig{Target: srv.URL, Client: client}) + if err != nil { + t.Fatal(err) + } + if trip.n == 0 { + t.Fatal("injected client was not used") + } + dir := t.TempDir() + out := filepath.Join(dir, "saved.yaml") + if err := SaveFlow(out, recorded); err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if strings.Contains(e.Name(), ".tmp") { + t.Fatalf("temp file left behind: %s", e.Name()) + } + } }) } + +func jsonServer(t *testing.T, body string) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + return srv +} + +type countTransport struct { + rt http.RoundTripper + n int +} + +func (c *countTransport) RoundTrip(req *http.Request) (*http.Response, error) { + c.n++ + if c.rt == nil { + return http.DefaultTransport.RoundTrip(req) + } + return c.rt.RoundTrip(req) +}