package contract import ( "encoding/json" "net/http/httptest" "reflect" "strings" "sync" "testing" apierrors "git.yuntuo.hk/k2safe/go-omni/errors" ) type address struct { City string `json:"city" binding:"required,max=30"` } type mixed struct { ID int64 `path:"userId,range=[1:100]"` Page int `form:"page,default=1,range=[1:50]"` Tags []string `form:"tag,optional" binding:"max=3"` Client string `header:"X-Client"` Name string `json:"name" binding:"required,max=20"` Active bool `json:"active"` Count *int `json:"count,optional"` Contact address `json:"contact"` Items []address `json:"items,optional"` Labels map[string]address `json:"labels,optional"` } const validBody = `{"name":"小明","active":false,"count":0,"contact":{"city":"杭州"}}` func bindMixed(body, query string) (mixed, error) { b := MustNew[mixed]() r := httptest.NewRequest("POST", "/users/7"+query, strings.NewReader(body)) r.Header.Set("Content-Type", "application/json") r.Header.Set("X-Client", "web") var v mixed return v, b.Bind(r, map[string]string{"userId": "7"}, &v) } func TestMixedSourcesAndPresence(t *testing.T) { b := MustNew[mixed]() r := httptest.NewRequest("POST", "/users/7?tag=a&tag=b&userId=99&name=query", strings.NewReader(validBody)) r.Header.Set("Content-Type", "application/json; charset=utf-8") r.Header.Set("X-Client", "web") v := mixed{Name: "previous"} if err := b.Bind(r, map[string]string{"userId": "7"}, &v); err != nil { t.Fatal(err) } if v.ID != 7 || v.Page != 1 || v.Name != "小明" || v.Active || v.Count == nil || *v.Count != 0 || len(v.Tags) != 2 || v.Client != "web" || v.Contact.City != "杭州" { t.Fatalf("bad binding: %+v", v) } r = httptest.NewRequest("POST", "/", strings.NewReader(strings.Replace(validBody, `,"count":0`, "", 1))) r.Header.Set("Content-Type", "application/json") r.Header.Set("X-Client", "web") if err := b.Bind(r, map[string]string{"userId": "7"}, &v); err != nil || v.Count != nil { t.Fatalf("missing != zero: %+v %v", v, err) } } func TestRejectInvalidRequests(t *testing.T) { cases := []struct{ name, body, query string }{ {"missing", `{"name":"a","contact":{"city":"杭州"}}`, ""}, {"empty", strings.Replace(validBody, `"小明"`, `""`, 1), ""}, {"caseSensitive", strings.Replace(validBody, `"name"`, `"Name"`, 1), ""}, {"unknown", strings.Replace(validBody, `"active":false`, `"active":false,"extra":1`, 1), ""}, {"duplicate", strings.Replace(validBody, `"active":false`, `"active":false,"active":true`, 1), ""}, {"nestedDuplicate", strings.Replace(validBody, `"city":"杭州"`, `"city":"杭州","city":"北京"`, 1), ""}, {"nestedRequired", strings.Replace(validBody, `{"city":"杭州"}`, `{}`, 1), ""}, {"nestedUnknown", strings.Replace(validBody, `{"city":"杭州"}`, `{"city":"杭州","other":0}`, 1), ""}, {"trailing", validBody + ` {}`, ""}, {"null", strings.Replace(validBody, `false`, `null`, 1), ""}, {"numberOverflow", strings.Replace(validBody, `"count":0`, `"count":99999999999999999999`, 1), ""}, {"arrayValidation", strings.TrimSuffix(validBody, "}") + `,"items":[{"city":""}]}`, ""}, {"mapValidation", strings.TrimSuffix(validBody, "}") + `,"labels":{"home":{"city":""}}}`, ""}, {"queryDuplicate", validBody, "?page=1&page=2"}, {"queryDefaultNotOnEmpty", validBody, "?page="}, {"range", validBody, "?page=51"}, {"malformedQuery", validBody, "?page=%zz"}, {"arrayLimit", validBody, "?tag=a&tag=b&tag=c&tag=d"}, } b := MustNew[mixed]() for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { r := httptest.NewRequest("POST", "/"+tc.query, strings.NewReader(tc.body)) r.Header.Set("Content-Type", "application/json") r.Header.Set("X-Client", "web") v := mixed{Name: "unchanged"} err := b.Bind(r, map[string]string{"userId": "7"}, &v) if err == nil || apierrors.From(err).Status != 400 { t.Fatalf("wanted 400: %v", err) } if v.Name != "unchanged" || v.Page != 0 { t.Fatal("failed binding modified target") } }) } } func TestBodyLimitsAndMediaType(t *testing.T) { type request struct { Name string `json:"name"` } b := MustNew[request]() for _, tc := range []struct { body, ct string limit int64 status int }{ {`{"name":"x"}`, "text/plain", 100, 415}, {`{"name":"long"}`, "application/json", 10, 413}, {`{"name":"x"}` + strings.Repeat(" ", 2048), "application/json", 1024, 413}, {`{"name":"x"}`, "application/json", 12, 0}, } { r := httptest.NewRequest("POST", "/", strings.NewReader(tc.body)) r.ContentLength = -1 r.Header.Set("Content-Type", tc.ct) var req request err := b.Bind(r, nil, &req, tc.limit) if tc.status == 0 { if err != nil { t.Fatal(err) } } else if err == nil || apierrors.From(err).Status != tc.status { t.Fatalf("status %d wanted: %v", tc.status, err) } } } func TestSchemaUsesSameRules(t *testing.T) { b := MustNew[mixed]() s := b.BodySchema() if len(s.Required) != 3 || s.Properties["count"].Nullable != true || s.Properties["name"].MaxLength == nil || *s.Properties["name"].MaxLength != 20 { t.Fatalf("wrong schema: %+v", s) } encoded, err := json.Marshal(s) if err != nil { t.Fatal(err) } if !strings.Contains(string(encoded), `"additionalProperties":false`) { t.Fatal(string(encoded)) } params := b.Parameters() if len(params) != 4 || params[1].Required || params[1].Schema.Default != json.Number("1") { t.Fatalf("wrong parameters: %+v", params) } // Returned documentation snapshots do not mutate concurrent binders. s.Properties["name"].MaxLength = nil if b.BodySchema().Properties["name"].MaxLength == nil { t.Fatal("shared schema") } if _, err = b.ResponseSchema(); err == nil { t.Fatal("request sources in response accepted") } } func TestRulesAndConcurrentReuse(t *testing.T) { type req struct { Score float64 `json:"score,options=1.0|2,range=(0:2]"` Email string `json:"email" binding:"email"` Value *int `json:"value" binding:"required"` } b := MustNew[req]() var wg sync.WaitGroup for i := 0; i < 24; i++ { wg.Add(1) go func() { defer wg.Done() r := httptest.NewRequest("POST", "/", strings.NewReader(`{"score":1,"email":"a@example.com","value":3}`)) r.Header.Set("Content-Type", "application/json") var v req if err := b.Bind(r, nil, &v); err != nil { t.Error(err) } }() } wg.Wait() for _, body := range []string{`{"score":0,"email":"a@example.com","value":1}`, `{"score":2,"email":"bad","value":1}`, `{"score":2,"email":"a@example.com","value":null}`, `{"score":2,"email":"a@example.com","value":0}`} { r := httptest.NewRequest("POST", "/", strings.NewReader(body)) r.Header.Set("Content-Type", "application/json") var v req if err := b.Bind(r, nil, &v); err == nil { t.Fatal(body) } } } func TestInvalidContracts(t *testing.T) { for _, tag := range []string{`json:"bad_name"`, `json:"a" form:"a"`, `json:"a,optionl"`, `json:"a,range=[5:2]"`, `json:"a,default=20,range=[1:10]"`, `json:"a,optional" binding:"required"`, `json:"a" binding:"unknown"`, `path:"a,optional"`, `json:"a" json:"b"`, `jsoon:"a"`, `json:"a,options=x"`} { typ := reflect.StructOf([]reflect.StructField{{Name: "Value", Type: reflect.TypeOf(0), Tag: reflect.StructTag(tag)}}) if _, err := ForType(typ); err == nil { t.Fatalf("accepted %s", tag) } } } func TestExactLargeIntegerConstraints(t *testing.T) { type req struct { Value int64 `json:"value,range=[:9007199254740992]"` } b := MustNew[req]() for _, tc := range []struct { body string valid bool }{{`{"value":9007199254740992}`, true}, {`{"value":9007199254740993}`, false}} { r := httptest.NewRequest("POST", "/", strings.NewReader(tc.body)) r.Header.Set("Content-Type", "application/json") var v req if err := b.Bind(r, nil, &v); (err == nil) != tc.valid { t.Fatalf("%s: %v", tc.body, err) } } type defaults struct { Value uint64 `form:"value,default=18446744073709551615,options=18446744073709551615"` } d := MustNew[defaults]() data, err := json.Marshal(d.Parameters()) if err != nil || !strings.Contains(string(data), "18446744073709551615") { t.Fatalf("lost precision: %s %v", data, err) } } func TestIntrinsicIntegerBounds(t *testing.T) { type req struct { Small uint8 `json:"small,range=[-1:500]"` Wide uint64 `json:"wide"` } b := MustNew[req]() s := b.BodySchema() if *s.Properties["small"].Minimum != 0 || *s.Properties["small"].Maximum != 255 || s.Properties["wide"].Format != "" { t.Fatalf("wrong integer bounds: %+v", s.Properties) } } func FuzzStrictBody(f *testing.F) { for _, seed := range []string{validBody, `{}`, `{"name":"a","name":"b"}`, `[null]`, `{"x":[]}`} { f.Add(seed) } binder := MustNew[mixed]() f.Fuzz(func(t *testing.T, body string) { if len(body) > 16384 { t.Skip() } req := httptest.NewRequest("POST", "/", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Client", "fuzz") var value mixed _ = binder.Bind(req, map[string]string{"userId": "1"}, &value, 16384) }) }