245 lines
8.9 KiB
Go
245 lines
8.9 KiB
Go
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)
|
|
})
|
|
}
|