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 <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-09-17 12:21:34 +02:00
parent f6e1b892bc
commit f669d056c9
3 changed files with 155 additions and 0 deletions

View File

@@ -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 {

View File

@@ -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))

View File

@@ -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)
}