Files
gitea/modules/web/binding/binding_test.go
T
e6ffbea888 refactor: move go-chi/binding into Gitea (#39528)
The `gitea.com/go-chi/binding` package only exists for Gitea, so it
moves into `modules/web/binding` to fix its bugs directly. Split out of
https://github.com/go-gitea/gitea/pull/39504.

- GET and HEAD always bind the query
- JSON `null` slice elements and nested `TrimSpace` fields bind
correctly
- Integer fields reject out-of-range values instead of wrapping
- An empty JSON body binds nothing and an unknown binding rule is an
error

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-10-02 15:37:41 +02:00

324 lines
13 KiB
Go

// Copyright 2014 Martini Authors
// Copyright 2014 The Macaron Authors
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package binding
import (
"bytes"
"cmp"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type (
Post struct {
Title string `form:"title" json:"title" binding:"Required"`
Content string `form:"content" json:"content"`
}
Person struct {
Name string `form:"name" json:"name" binding:"Required"`
Email string `form:"email" json:"email"`
}
BlogPost struct {
Post
ID int `json:"id" binding:"Required"`
Ignored string `form:"-" json:"-"`
Ratings []int `form:"rating" json:"ratings"`
Author Person `json:"author"`
Coauthor *Person `json:"coauthor"`
HeaderImage *multipart.FileHeader `json:"-"`
Pictures []*multipart.FileHeader `form:"picture" json:"-"`
}
EmbedPerson struct {
*Person
}
Group struct {
Name string `json:"name" binding:"Required"`
People []*Person `json:"people" binding:"MinSize(1)"`
}
Everything struct {
Int int
Int8 int8
Int16 int16
Uint uint
Uint8 uint8
Uint64 uint64
Bool bool
BoolOn bool
String string
IntPointer *int
Map map[string]string
}
Rules struct {
AlphaDashDot string `binding:"AlphaDashDot"`
MinSize string `binding:"MinSize(3)"`
MinSizeSlice []string `binding:"MinSize(2)"`
MaxSize string `binding:"MaxSize(1)"`
MaxSizeSlice []string `binding:"MaxSize(1)"`
Range int `binding:"Range(1,2)"`
In string `binding:"In(a,b)"`
Include string `binding:"Include(x)"`
Pointer *string `binding:"MaxSize(1)"`
}
RequiredForm struct {
String string `binding:"Required"`
Int int `binding:"Required"`
Bool bool `binding:"Required"`
Slice []int `binding:"Required"`
Pointer *string `binding:"Required"`
PointerToZero *string `binding:"Required"`
}
AnyString string
Trimmed struct {
Value AnyString `binding:"TrimSpace;Required"`
Pointer *AnyString `binding:"TrimSpace;Required"`
}
TrimForm struct {
Trimmed
Nested Trimmed
Items []Trimmed
PointerItems []*Trimmed
}
InvalidRule struct {
Field string `binding:"InvalidRule"`
}
)
func TestBind(t *testing.T) {
const formType, jsonType = "application/x-www-form-urlencoded", "application/json"
requiredTitle := Error{FieldNames: []string{"Title"}, Classification: ErrRequired, Message: "Required"}
unsupported := Errors{{Classification: errContentType, Message: "Unsupported Content-Type"}}
assert.Equal(t, "Required", requiredTitle.Error())
cases := []struct {
name string
method string
target string
contentType string
body string
expected any
errs Errors
}{
{name: "form missing required", contentType: formType, body: "content=C", expected: Post{Content: "C"}, errs: Errors{requiredTitle}},
{
name: "form malformed", contentType: formType, body: "title=%2", expected: Post{},
errs: Errors{{Classification: errDeserialization, Message: `invalid URL escape "%2"`}, requiredTitle},
},
{
name: "form nested and embedded", contentType: formType, body: "title=T&content=C&id=1&name=N&rating=4&rating=3&-=x&ignored=x",
expected: BlogPost{Post: Post{Title: "T", Content: "C"}, ID: 1, Ratings: []int{4, 3}, Author: Person{Name: "N"}},
},
{name: "query on POST", target: "/?title=T", contentType: formType, expected: Post{Title: "T"}},
{name: "query on GET", method: http.MethodGet, target: "/?title=T&content=C", expected: Post{Title: "T", Content: "C"}},
{name: "query on GET with content type", method: http.MethodGet, target: "/?title=T", contentType: jsonType, body: `{"title":"B"}`, expected: Post{Title: "T"}},
{name: "query on HEAD with content type", method: http.MethodHead, target: "/?title=T", contentType: jsonType, body: `{"title":"B"}`, expected: Post{Title: "T"}},
{name: "embedded pointer", method: http.MethodGet, target: "/?name=N&email=E", expected: EmbedPerson{&Person{Name: "N", Email: "E"}}},
{name: "embedded pointer not bound", method: http.MethodGet, target: "/", expected: EmbedPerson{}},
{name: "DELETE without content type", method: http.MethodDelete, target: "/?title=T", expected: Post{Title: "T"}},
{name: "POST without content type", target: "/?title=T", expected: Post{}, errs: unsupported},
{name: "unsupported content type", method: http.MethodPatch, contentType: "text/plain", body: "title=T", expected: Post{}, errs: unsupported},
{
name: "json ignores form tags", method: http.MethodPut, contentType: jsonType, body: `{"title":"T","content":"C","id":1,"rating":[1],"ratings":[4,3],"author":{"name":"N"}}`,
expected: BlogPost{Post: Post{Title: "T", Content: "C"}, ID: 1, Ratings: []int{4, 3}, Author: Person{Name: "N"}},
},
{
name: "json malformed", contentType: jsonType, body: `{"title":"T"`, expected: Post{Title: "T"},
errs: Errors{{Classification: errDeserialization, Message: "jsontext: unexpected EOF after offset 12"}},
},
{
name: "json null slice element", contentType: jsonType, body: `{"name":"G","people":[null,{"name":"N"}]}`,
expected: Group{Name: "G", People: []*Person{nil, {Name: "N"}}},
},
{
name: "json slice element required", contentType: jsonType, body: `{"name":"G","people":[{"email":"E"}]}`,
expected: Group{Name: "G", People: []*Person{{Email: "E"}}},
errs: Errors{{FieldNames: []string{"Name"}, Classification: ErrRequired, Message: "Required"}},
},
{
name: "form type conversion", contentType: formType,
body: "int=-1&int8=-8&int16=&uint=1&uint8=8&uint64=64&bool=true&bool_on=on&string=s&int_pointer=7",
expected: Everything{Int: -1, Int8: -8, Uint: 1, Uint8: 8, Uint64: 64, Bool: true, BoolOn: true, String: "s", IntPointer: new(7)},
},
{
name: "form type conversion errors", contentType: formType,
body: "int=x&int8=128&uint=-1&uint8=256&bool=maybe&int_pointer=x&map=x",
expected: Everything{},
errs: Errors{
{FieldNames: []string{"Int"}, Classification: errTypeCast, Message: "Value could not be parsed as integer"},
{FieldNames: []string{"Int8"}, Classification: errTypeCast, Message: "Value could not be parsed as integer"},
{FieldNames: []string{"Uint"}, Classification: errTypeCast, Message: "Value could not be parsed as unsigned integer"},
{FieldNames: []string{"Uint8"}, Classification: errTypeCast, Message: "Value could not be parsed as unsigned integer"},
{FieldNames: []string{"Bool"}, Classification: errTypeCast, Message: "Value could not be parsed as boolean"},
{FieldNames: []string{"IntPointer"}, Classification: errTypeCast, Message: "Value could not be parsed as integer"},
{FieldNames: []string{"Map"}, Classification: errDeserialization, Message: "unsupported type: map"},
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(cmp.Or(tc.method, http.MethodPost), cmp.Or(tc.target, "/"), strings.NewReader(tc.body))
if tc.contentType != "" {
req.Header.Set("Content-Type", tc.contentType)
}
actual := reflect.New(reflect.TypeOf(tc.expected))
assert.Equal(t, tc.errs, NewBinder().Bind(req, actual.Interface()))
assert.Equal(t, tc.expected, actual.Elem().Interface())
})
}
}
func TestBindMultipartForm(t *testing.T) {
readFile := func(t *testing.T, fileHeader *multipart.FileHeader) string {
file, err := fileHeader.Open()
require.NoError(t, err)
defer file.Close()
content, err := io.ReadAll(file)
require.NoError(t, err)
return string(content)
}
for _, parsedBefore := range []bool{false, true} {
t.Run(fmt.Sprintf("parsed before %v", parsedBefore), func(t *testing.T) {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
for _, field := range [][2]string{{"title", "T"}, {"id", "1"}, {"rating", "3"}, {"rating", "5"}, {"name", "N"}} {
require.NoError(t, writer.WriteField(field[0], field[1]))
}
for _, file := range [][2]string{{"header_image", "header.txt"}, {"picture", "a.txt"}, {"picture", "b.txt"}} {
fileWriter, err := writer.CreateFormFile(file[0], file[1])
require.NoError(t, err)
_, err = fileWriter.Write([]byte("content of " + file[1]))
require.NoError(t, err)
}
require.NoError(t, writer.Close())
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
if parsedBefore {
assert.Equal(t, "T", req.FormValue("title"))
}
var actual BlogPost
assert.Empty(t, NewBinder().Bind(req, &actual))
assert.Equal(t, Post{Title: "T"}, actual.Post)
assert.Equal(t, 1, actual.ID)
assert.Equal(t, []int{3, 5}, actual.Ratings)
assert.Equal(t, Person{Name: "N"}, actual.Author)
assert.Equal(t, "content of header.txt", readFile(t, actual.HeaderImage))
require.Len(t, actual.Pictures, 2)
assert.Equal(t, "content of a.txt", readFile(t, actual.Pictures[0]))
assert.Equal(t, "content of b.txt", readFile(t, actual.Pictures[1]))
})
}
for name, contentType := range map[string]string{"malformed body": "multipart/form-data; boundary=x", "missing boundary": "multipart/form-data"} {
t.Run(name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("--x\r\nbroken"))
req.Header.Set("Content-Type", contentType)
var actual Post
errs := NewBinder().Bind(req, &actual)
require.Len(t, errs, 2)
assert.Equal(t, errDeserialization, errs[0].Classification)
assert.Equal(t, ErrRequired, errs[1].Classification)
})
}
}
func TestValidate(t *testing.T) {
cases := []struct {
name string
data any
expected any
errs Errors
}{
{name: "rules skip zero values", data: &Rules{}},
{
name: "rules with valid values",
data: &Rules{AlphaDashDot: "a-b_c.d", MinSize: "abc", MinSizeSlice: []string{"a", "b"}, MaxSize: "é", MaxSizeSlice: []string{"a"}, Range: 2, In: "b", Include: "axb", Pointer: new("a")},
},
{
name: "rules with invalid values",
data: &Rules{AlphaDashDot: "a,b", MinSize: "ab", MinSizeSlice: []string{"a"}, MaxSize: "ab", MaxSizeSlice: []string{"a", "b"}, Range: 3, In: "c", Include: "abc", Pointer: new("ab")},
errs: Errors{
{FieldNames: []string{"AlphaDashDot"}, Classification: ErrAlphaDashDot, Message: "AlphaDashDot"},
{FieldNames: []string{"MinSize"}, Classification: ErrMinSize, Message: "MinSize"},
{FieldNames: []string{"MinSizeSlice"}, Classification: ErrMinSize, Message: "MinSize"},
{FieldNames: []string{"MaxSize"}, Classification: ErrMaxSize, Message: "MaxSize"},
{FieldNames: []string{"MaxSizeSlice"}, Classification: ErrMaxSize, Message: "MaxSize"},
{FieldNames: []string{"Range"}, Classification: ErrRange, Message: "Range"},
{FieldNames: []string{"In"}, Classification: ErrIn, Message: "In"},
{FieldNames: []string{"Include"}, Classification: ErrInclude, Message: "Include"},
{FieldNames: []string{"Pointer"}, Classification: ErrMaxSize, Message: "MaxSize"},
},
},
{
name: "required",
data: &RequiredForm{PointerToZero: new("")},
errs: Errors{
{FieldNames: []string{"String"}, Classification: ErrRequired, Message: "Required"},
{FieldNames: []string{"Int"}, Classification: ErrRequired, Message: "Required"},
{FieldNames: []string{"Bool"}, Classification: ErrRequired, Message: "Required"},
{FieldNames: []string{"Slice"}, Classification: ErrRequired, Message: "Required"},
{FieldNames: []string{"PointerToZero"}, Classification: ErrRequired, Message: "Required"},
},
},
{
name: "nested embedded and pointer structs",
data: &BlogPost{Coauthor: &Person{}},
errs: Errors{
{FieldNames: []string{"Title"}, Classification: ErrRequired, Message: "Required"},
{FieldNames: []string{"ID"}, Classification: ErrRequired, Message: "Required"},
{FieldNames: []string{"Name"}, Classification: ErrRequired, Message: "Required"},
{FieldNames: []string{"Name"}, Classification: ErrRequired, Message: "Required"},
},
},
{
name: "TrimSpace in nested embedded and slice elements",
data: &TrimForm{Trimmed: Trimmed{Value: " a ", Pointer: new(AnyString(" b "))}, Nested: Trimmed{Value: " "}, Items: []Trimmed{{Value: " c "}}, PointerItems: []*Trimmed{{Value: " d ", Pointer: new(AnyString(" "))}}},
expected: &TrimForm{Trimmed: Trimmed{Value: "a", Pointer: new(AnyString("b"))}, Nested: Trimmed{Value: ""}, Items: []Trimmed{{Value: "c"}}, PointerItems: []*Trimmed{{Value: "d", Pointer: new(AnyString(""))}}},
errs: Errors{
{FieldNames: []string{"Value"}, Classification: ErrRequired, Message: "Required"},
{FieldNames: []string{"Pointer"}, Classification: ErrRequired, Message: "Required"},
},
},
{
name: "TrimSpace on non-string",
data: &struct {
Float float64 `binding:"TrimSpace"`
}{Float: 1.5},
errs: Errors{{FieldNames: []string{"Float"}, Classification: errTypeCast, Message: "TrimSpace"}},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.errs, NewBinder().Validate(t.Context(), tc.data))
if tc.expected != nil {
assert.Equal(t, tc.expected, tc.data)
}
})
}
assert.PanicsWithValue(t, `Invalid binding rule: "InvalidRule"`, func() {
NewBinder().Validate(t.Context(), &InvalidRule{})
})
}