From 5af32791af738fe03f986b4ba20e8f63d1ced075 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sun, 7 Jun 2026 22:55:06 -0400 Subject: [PATCH] test: fuzz targets for query binder, error writer, roster enum parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage-guided fuzzing of the three panic-prone pure-function seams the PRD flags as the lenient surface: - FuzzQueryBinder: no accessor panics; first-failure-wins is monotone (a later successful read never clears an earlier 400). - FuzzWriteError: arbitrary user-derived message bytes (control chars, invalid UTF-8) always yield valid gRPC-status contract JSON with the code echoed, details=[], and the code->status mapping intact. - FuzzParseRosterType: never panics; any clean parse round-trips through its enum name. Real test assets — independent of the smoke wiring, land separately. ~870k execs across the three, zero crashes. This was generated by AI --- rest/query_fuzz_test.go | 75 +++++++++++++++++++++++++++++ rest/write_fuzz_test.go | 69 ++++++++++++++++++++++++++ types/rostertype_parse_fuzz_test.go | 56 +++++++++++++++++++++ 3 files changed, 200 insertions(+) create mode 100644 rest/query_fuzz_test.go create mode 100644 rest/write_fuzz_test.go create mode 100644 types/rostertype_parse_fuzz_test.go diff --git a/rest/query_fuzz_test.go b/rest/query_fuzz_test.go new file mode 100644 index 0000000..0393ba0 --- /dev/null +++ b/rest/query_fuzz_test.go @@ -0,0 +1,75 @@ +package rest + +import ( + "net/url" + "testing" +) + +// FuzzQueryBinder drives the query binder the way handlers do — bind a +// representative spread of field types, then read Err — against arbitrary +// query strings. It pins two invariants the lenient-binding surface (PRD #112: +// name-or-number, repeated-by-repetition, snake/camel dual spelling, bracket +// folds) must never violate, whatever the input: +// +// - no accessor panics (the binder digests any url.Values, including +// bracket keys, mismatched spellings, and pathological repetition); +// - first-failure-wins is monotone: once Err() is non-nil it stays the SAME +// error for the rest of the bind. The handler protocol (bind everything, +// check Err once) is only safe if a later successful read can't clear an +// earlier failure — this is the property that makes a forgotten field read +// harmless rather than a silently-dropped 400. +// +// Field names are chosen to exercise every code path: a multi-word name +// (snakeToCamel + camel-spelling fold), scalar vs slice, and all three parsed +// types (uint32, bool, string). Run: go test ./rest -run x -fuzz FuzzQueryBinder +func FuzzQueryBinder(f *testing.F) { + for _, seed := range []string{ + "", + "status_id=1&status_id=5", // repeated scalar → too-many-values + "status_id=1&statusId=5", // both spellings → camel wins + "per_page=notanumber", // uint32 parse failure + "include_hidden=maybe", // bool parse failure + "status_id[0]=5", // bracket fold + "status_id[x]=5", // non-numeric bracket content + "per_page=1&per_page=2&per_page=3", // pathological repetition + "ticket_state=open&ticket_state=closed", + "a[b][c]=1", // greedy bracket fold to unknown base + } { + f.Add(seed) + } + + f.Fuzz(func(t *testing.T, raw string) { + values, err := url.ParseQuery(raw) + if err != nil { + return // malformed escaping is stdlib's contract, not the binder's + } + b := newQueryBinder(values) + + // Bind a spread that hits scalar/slice × uint32/bool/string and a + // multi-word name (camel-fold path). After each read, the first error + // must never change once set. + var firstErr error + check := func() { + if got := b.Err(); got != nil { + if firstErr == nil { + firstErr = got + } else if got != firstErr { + t.Fatalf("Err() mutated after first failure: was %v, now %v", firstErr, got) + } + } else if firstErr != nil { + t.Fatalf("Err() reverted to nil after failure %v", firstErr) + } + } + + b.uint32Field("per_page") + check() + b.uint32SliceField("status_id") + check() + b.boolField("include_hidden") + check() + b.stringField("ticket_state") + check() + b.stringSliceField("ticket_state") + check() + }) +} diff --git a/rest/write_fuzz_test.go b/rest/write_fuzz_test.go new file mode 100644 index 0000000..7905601 --- /dev/null +++ b/rest/write_fuzz_test.go @@ -0,0 +1,69 @@ +package rest + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// FuzzWriteError pins the error choke point's wire invariant against arbitrary +// message content. Error messages on this API embed raw user input verbatim — +// the frozen "parsing field ...: ... parsing \"\"" and "type mismatch ... +// error: " strings carry whatever bytes the caller sent, including +// control characters and invalid UTF-8. Whatever goes in, the response must +// stay a valid gRPC-status JSON body: parseable, code echoed, details an +// always-present empty array (emit-everything), Content-Type application/json, +// and the HTTP status exactly the code→status table's answer. +// +// json.Marshal sanitizes invalid UTF-8 to U+FFFD, so the message does NOT +// round-trip byte-for-byte — the invariant is "always valid contract JSON", +// not "message survives unchanged". Run: +// go test ./rest -run x -fuzz FuzzWriteError +func FuzzWriteError(f *testing.F) { + for _, seed := range []struct { + c int + msg string + }{ + {int(codeInvalidArgument), "type mismatch, parameter: roster, error: combat is not valid"}, + {int(codeNotFound), "no profile found for username: john/doe"}, + {int(codeInvalidArgument), "parsing field \"per_page\": strconv.ParseUint: parsing \"\xff\xfe\": invalid syntax"}, + {int(codeUnknown), ""}, + {999, "code outside the frozen table"}, + {int(codeInvalidArgument), "quotes \" and newlines \n and tabs \t"}, + } { + f.Add(seed.c, seed.msg) + } + + f.Fuzz(func(t *testing.T, codeInt int, msg string) { + rr := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/api/v1/fuzz", nil) + + // "%s" with the message as the sole arg mirrors how callers embed raw + // user input; the format string itself is always a constant literal. + writeError(rr, r, code(codeInt), "%s", msg) + + res := rr.Result() + if ct := res.Header.Get("Content-Type"); ct != "application/json" { + t.Fatalf("Content-Type = %q, want application/json", ct) + } + if want := code(codeInt).httpStatus(); rr.Code != want { + t.Fatalf("status = %d, want %d for code %d", rr.Code, want, codeInt) + } + + body := rr.Body.Bytes() + if !json.Valid(body) { + t.Fatalf("response body is not valid JSON: %q", body) + } + var got statusBody + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("body did not decode into statusBody: %v (%q)", err, body) + } + if got.Code != code(codeInt) { + t.Fatalf("decoded code = %d, want %d", got.Code, codeInt) + } + if got.Details == nil || len(got.Details) != 0 { + t.Fatalf("details = %v, want always-present empty array", got.Details) + } + }) +} diff --git a/types/rostertype_parse_fuzz_test.go b/types/rostertype_parse_fuzz_test.go new file mode 100644 index 0000000..ed82b53 --- /dev/null +++ b/types/rostertype_parse_fuzz_test.go @@ -0,0 +1,56 @@ +package types_test + +import ( + "testing" + + "github.com/7cav/api/types" +) + +// FuzzParseRosterType fuzzes the RosterType path-parameter parser — the +// name-or-number enum binding the goldens freeze (PRD #112). Two invariants +// hold for any input: +// +// - it never panics (base-0 ParseInt sees hex/octal/binary/underscore forms, +// huge magnitudes, negatives, empty — all must resolve to a clean error, +// never a crash); +// - a non-error result is always a CATALOGED value that round-trips: its +// name re-parses to the identical value. This is the property the handler +// relies on when it marshals the bound enum back as its name string — a +// value that parsed but had no name would emit a broken wire enum. +// +// Run: go test ./types -run x -fuzz FuzzParseRosterType +func FuzzParseRosterType(f *testing.F) { + for _, seed := range []string{ + "ROSTER_TYPE_COMBAT", // canonical name + "1", // decimal number form + "0x1", // base-0 hex (frozen leniency) + "0b10", // base-0 binary + "01", // leading-zero octal + "1_0", // digit underscores + "ROSTER_TYPE_UNSPECIFIED", // zero value + "99", // numeric but uncataloged + "-1", // negative + "99999999999999999999", // overflows int32 + "combat", // lowercase, not a name + "", // empty + "ROSTER_TYPE_COMBAT ", // trailing space (not trimmed) + } { + f.Add(seed) + } + + f.Fuzz(func(t *testing.T, val string) { + got, err := types.ParseRosterType(val) + if err != nil { + return // any rejection is fine — the only forbidden outcome is a panic + } + // Parsed clean → must be cataloged and round-trip through its name. + name := got.String() + back, err := types.ParseRosterType(name) + if err != nil { + t.Fatalf("ParseRosterType(%q) = %v (name %q), but its name failed to re-parse: %v", val, got, name, err) + } + if back != got { + t.Fatalf("round-trip mismatch: ParseRosterType(%q) = %v, but ParseRosterType(%q) = %v", val, got, name, back) + } + }) +}