Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions rest/query_fuzz_test.go
Original file line number Diff line number Diff line change
@@ -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()
})
}
69 changes: 69 additions & 0 deletions rest/write_fuzz_test.go
Original file line number Diff line number Diff line change
@@ -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 \"<raw>\"" and "type mismatch ...
// error: <raw>" 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)
}
})
}
56 changes: 56 additions & 0 deletions types/rostertype_parse_fuzz_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}