Something went wrong. Try again.
A community based topic aggregation platform built on atproto
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242package reqbody
import ( "errors" "net/http/httptest" "strings" "testing")
type testPayload struct { Name string `json:"name"` Count int `json:"count"`}
// decode runs DecodeJSON against a synthetic POST carrying body.func decode(t *testing.T, body string, limit Limit, dst any, opts ...DecodeOption) error { t.Helper() r := httptest.NewRequest("POST", "/xrpc/test", strings.NewReader(body)) w := httptest.NewRecorder() return DecodeJSON(w, r, limit, dst, opts...)}
func TestDecodeJSON_ValidBody(t *testing.T) { var got testPayload if err := decode(t, `{"name":"coves","count":3}`, LimitTiny, &got); err != nil { t.Fatalf("DecodeJSON returned error for valid body: %v", err) } if got.Name != "coves" || got.Count != 3 { t.Fatalf("decoded payload = %+v, want {coves 3}", got) }}
func TestDecodeJSON_TrailingWhitespaceAccepted(t *testing.T) { var got testPayload if err := decode(t, "{\"name\":\"x\"} \n\t", LimitTiny, &got); err != nil { t.Fatalf("trailing whitespace must be accepted, got error: %v", err) }}
func TestDecodeJSON_OverLimitReturnsTooLarge(t *testing.T) { // A single long JSON string is the exact memory-amplification shape the // limit exists to stop. body := `{"name":"` + strings.Repeat("a", int(LimitTiny)) + `"}` var got testPayload err := decode(t, body, LimitTiny, &got)
var tooLarge *TooLargeError if !errors.As(err, &tooLarge) { t.Fatalf("want *TooLargeError, got %T: %v", err, err) } if tooLarge.Limit != int64(LimitTiny) { t.Fatalf("TooLargeError.Limit = %d, want %d", tooLarge.Limit, int64(LimitTiny)) }}
// TestDecodeJSON_OverLimitPaddingReturnsTooLarge pins the failure-open bug the// original dec.More() implementation had: a small, valid JSON value padded// past the cap with whitespace was ACCEPTED — More() swallowed the// MaxBytesReader error, so no 413 was written and nothing was logged. The// clean-EOF check must classify that read error as TooLargeError.func TestDecodeJSON_OverLimitPaddingReturnsTooLarge(t *testing.T) { body := `{"name":"a"}` + strings.Repeat(" ", int(LimitTiny)*2) var got testPayload err := decode(t, body, LimitTiny, &got)
var tooLarge *TooLargeError if !errors.As(err, &tooLarge) { t.Fatalf("padded over-limit body must be rejected as too large, got %T: %v", err, err) }}
// TestDecodeJSON_ValueAtLimitTrailingObjectRejected is the smuggling variant// of the same bug: value ends inside the cap, a second object hides beyond// it. The rejection may classify as either size or trailing data, but it must// never be a success.func TestDecodeJSON_ValueAtLimitTrailingObjectRejected(t *testing.T) { body := `{"name":"a"}` + strings.Repeat(" ", int(LimitTiny)) + `{"name":"EVIL"}` var got testPayload if err := decode(t, body, LimitTiny, &got); err == nil { t.Fatal("a trailing object hidden beyond the size limit was silently accepted") }}
func TestDecodeJSON_ExactlyAtLimitAccepted(t *testing.T) { // http.MaxBytesReader permits exactly n bytes; the failure must come // only from byte n+1. prefix, suffix := `{"name":"`, `"}` fill := int(LimitTiny) - len(prefix) - len(suffix) body := prefix + strings.Repeat("a", fill) + suffix if len(body) != int(LimitTiny) { t.Fatalf("test bug: body is %d bytes, want %d", len(body), int64(LimitTiny)) }
var got testPayload if err := decode(t, body, LimitTiny, &got); err != nil { t.Fatalf("body exactly at limit must decode, got error: %v", err) }}
// TestDecodeJSON_OneByteOverLimitRejected pins the boundary tightly: the// exact-limit test above plus a far-over test would both pass an off-by-one// that admits limit+1.func TestDecodeJSON_OneByteOverLimitRejected(t *testing.T) { prefix, suffix := `{"name":"`, `"}` fill := int(LimitTiny) - len(prefix) - len(suffix) + 1 body := prefix + strings.Repeat("a", fill) + suffix if len(body) != int(LimitTiny)+1 { t.Fatalf("test bug: body is %d bytes, want %d", len(body), int64(LimitTiny)+1) }
var got testPayload var tooLarge *TooLargeError if err := decode(t, body, LimitTiny, &got); !errors.As(err, &tooLarge) { t.Fatalf("body one byte over the limit must be too large, got %v", err) }}
func TestDecodeJSON_MalformedSyntax(t *testing.T) { var got testPayload err := decode(t, `{"name":`, LimitTiny, &got)
var malformed *MalformedError if !errors.As(err, &malformed) { t.Fatalf("want *MalformedError for truncated JSON, got %T: %v", err, err) }}
func TestDecodeJSON_EmptyBody(t *testing.T) { var got testPayload var malformed *MalformedError if err := decode(t, "", LimitTiny, &got); !errors.As(err, &malformed) { t.Fatalf("want *MalformedError for empty body, got %v", err) }}
func TestDecodeJSON_TypeMismatch(t *testing.T) { var got testPayload var malformed *MalformedError if err := decode(t, `{"count":"not-a-number"}`, LimitTiny, &got); !errors.As(err, &malformed) { t.Fatalf("want *MalformedError for type mismatch, got %v", err) }}
func TestDecodeJSON_TrailingDataRejected(t *testing.T) { // The stray-bracket cases pin the second dec.More() hole: More() returns // false on a closing delimiter, so `{...}}` used to pass as clean. for name, body := range map[string]string{ "second object": `{"name":"a"}{"name":"b"}`, "trailing bytes": `{"name":"a"}garbage`, "stray closing brace": `{"name":"a"}}`, "stray closing bracket": `{"name":"a"}]`, "bracket salad": `{"name":"a"}}}}]]]`, } { t.Run(name, func(t *testing.T) { var got testPayload var malformed *MalformedError if err := decode(t, body, LimitTiny, &got); !errors.As(err, &malformed) { t.Fatalf("want *MalformedError for trailing data, got %v", err) } }) }}
// TestDecodeJSON_TrailingDataCarriesSentinel lets callers count smuggling// probes separately from ordinary bad JSON.func TestDecodeJSON_TrailingDataCarriesSentinel(t *testing.T) { var got testPayload err := decode(t, `{"name":"a"}{"name":"b"}`, LimitTiny, &got) if !errors.Is(err, ErrTrailingData) { t.Fatalf("trailing-data rejection must wrap ErrTrailingData, got %v", err) }}
func TestDecodeJSON_UnknownFieldsAllowedByDefault(t *testing.T) { // atProto tolerates unknown fields; the default must not reject clients // sending a newer schema. var got testPayload if err := decode(t, `{"name":"a","futureField":true}`, LimitTiny, &got); err != nil { t.Fatalf("unknown fields must be allowed by default, got error: %v", err) }}
func TestDecodeJSON_DisallowUnknownFieldsOption(t *testing.T) { var got testPayload err := decode(t, `{"name":"a","probe":true}`, LimitTiny, &got, WithDisallowUnknownFields())
var malformed *MalformedError if !errors.As(err, &malformed) { t.Fatalf("want *MalformedError with WithDisallowUnknownFields, got %v", err) }}
// TestDecodeJSON_NonPointerDstPanics: a non-pointer dst is a wiring bug in// the handler, not client input. It must panic (chi's Recoverer makes that a// 500) rather than answer 400 to every caller forever.func TestDecodeJSON_NonPointerDstPanics(t *testing.T) { defer func() { if recover() == nil { t.Fatal("DecodeJSON with a non-pointer dst must panic") } }() var got testPayload _ = decode(t, `{"name":"a"}`, LimitTiny, got) // no & — the bug under test}
// TestDecodeJSON_NonPositiveLimitPanics: a zero or negative limit would make// MaxBytesReader reject every request as too large — a dead endpoint blaming// its clients. Refuse loudly instead.func TestDecodeJSON_NonPositiveLimitPanics(t *testing.T) { for name, limit := range map[string]Limit{"zero": 0, "negative": -1} { t.Run(name, func(t *testing.T) { defer func() { if recover() == nil { t.Fatalf("DecodeJSON with %s limit must panic", name) } }() var got testPayload _ = decode(t, `{"name":"a"}`, limit, &got) }) }}
// TestLimitOrdering pins the invariant the router relies on: the global// backstop must never undercut ANY endpoint tier — derived over the full// tier set, so adding a bigger tier without bumping LimitGlobal fails here// (and at the compile-time assert in reqbody.go).func TestLimitOrdering(t *testing.T) { tiers := []Limit{LimitTiny, LimitSmall, LimitMedium, LimitLarge, LimitImage} for i := 1; i < len(tiers); i++ { if tiers[i-1] >= tiers[i] { t.Fatalf("size tiers must be strictly increasing; tier %d (%d) >= tier %d (%d)", i-1, tiers[i-1], i, tiers[i]) } } for _, tier := range tiers { if LimitGlobal < tier { t.Fatalf("LimitGlobal (%d) must cover every endpoint tier, but tier %d exceeds it", int64(LimitGlobal), int64(tier)) } }}