Files
3x-ui/web/middleware/validate_test.go
MHSanaei 7fda988fb2 feat(backend): gate request bodies with go-playground/validator
Add a generic BindAndValidate helper in web/middleware that wraps gin's
content-aware binder with an explicit validator.Struct call and emits a
structured `entity.Msg{Obj: ValidationPayload{Issues...}}` on failure so
the frontend can map each issue to an i18n key.

Tag the user-facing fields on model.Inbound, model.Node, and
entity.AllSetting with the range/enum constraints they were previously
relying on hand-rolled CheckValid logic (or nothing) to enforce, and
wire the helper into the inbound/node/settings controllers that bind
those structs directly. Promotes validator/v10 from indirect to direct
require, plus six unit tests covering valid payloads, range violations,
enum violations, malformed JSON, in-place binding, and JSON-only strict
mode.

This is PR1 of a planned end-to-end Zod rollout — controllers using
local form structs (custom_geo, setEnable, fallbacks, client) keep
their existing handling and will be migrated as their schemas firm up.
2026-05-25 19:17:54 +02:00

208 lines
5.8 KiB
Go

package middleware
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/web/entity"
)
type sampleBody struct {
Port int `json:"port" form:"port" validate:"gte=1,lte=65535"`
Protocol string `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan"`
Tag string `json:"tag" form:"tag"`
}
func newRouter(handler gin.HandlerFunc) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/submit", handler)
return r
}
func decodeMsg(t *testing.T, body string) entity.Msg {
t.Helper()
var msg entity.Msg
if err := json.Unmarshal([]byte(body), &msg); err != nil {
t.Fatalf("decode msg: %v (body=%q)", err, body)
}
return msg
}
func TestBindAndValidate_ValidPayloadPassesThrough(t *testing.T) {
r := newRouter(func(c *gin.Context) {
got, ok := BindAndValidate[sampleBody](c)
if !ok {
t.Fatalf("expected ok=true, got false (body should be valid)")
}
if got.Port != 443 || got.Protocol != "vless" || got.Tag != "inbound-443" {
t.Fatalf("decoded payload mismatch: %+v", got)
}
c.JSON(http.StatusOK, entity.Msg{Success: true, Msg: "ok"})
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/submit",
strings.NewReader(`{"port":443,"protocol":"vless","tag":"inbound-443"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d (body=%s)", rec.Code, http.StatusOK, rec.Body.String())
}
if msg := decodeMsg(t, rec.Body.String()); !msg.Success {
t.Fatalf("expected Success=true; got %+v", msg)
}
}
func TestBindAndValidate_PortOutOfRangeIsRejected(t *testing.T) {
r := newRouter(func(c *gin.Context) {
if _, ok := BindAndValidate[sampleBody](c); ok {
t.Fatal("expected ok=false on invalid port; got true")
}
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/submit",
strings.NewReader(`{"port":70000,"protocol":"vless"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(rec, req)
msg := decodeMsg(t, rec.Body.String())
if msg.Success {
t.Fatalf("expected Success=false; got %+v", msg)
}
payload, err := payloadFromObj(msg.Obj)
if err != nil {
t.Fatalf("payload extraction: %v", err)
}
found := false
for _, issue := range payload.Issues {
if issue.Field == "port" && issue.Rule == "lte" {
found = true
break
}
}
if !found {
t.Fatalf("expected an Issue for field=port rule=lte; got %+v", payload.Issues)
}
}
func TestBindAndValidate_ProtocolEnumIsRejected(t *testing.T) {
r := newRouter(func(c *gin.Context) {
if _, ok := BindAndValidate[sampleBody](c); ok {
t.Fatal("expected ok=false on invalid protocol; got true")
}
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/submit",
strings.NewReader(`{"port":443,"protocol":"unknown"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(rec, req)
msg := decodeMsg(t, rec.Body.String())
payload, err := payloadFromObj(msg.Obj)
if err != nil {
t.Fatalf("payload extraction: %v", err)
}
found := false
for _, issue := range payload.Issues {
if issue.Field == "protocol" && issue.Rule == "oneof" {
found = true
}
}
if !found {
t.Fatalf("expected an Issue for field=protocol rule=oneof; got %+v", payload.Issues)
}
}
func TestBindAndValidate_MalformedJSONReturnsMessageButNoIssues(t *testing.T) {
r := newRouter(func(c *gin.Context) {
if _, ok := BindAndValidate[sampleBody](c); ok {
t.Fatal("expected ok=false on malformed JSON; got true")
}
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/submit",
strings.NewReader(`{"port":}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(rec, req)
msg := decodeMsg(t, rec.Body.String())
if msg.Success {
t.Fatal("expected Success=false on malformed JSON")
}
payload, err := payloadFromObj(msg.Obj)
if err != nil {
t.Fatalf("payload extraction: %v", err)
}
if len(payload.Issues) != 0 {
t.Fatalf("expected empty Issues for parse error; got %+v", payload.Issues)
}
if payload.Message == "" {
t.Fatal("expected non-empty Message describing the parse error")
}
}
func TestBindAndValidateInto_PreservesPrePopulatedFields(t *testing.T) {
r := newRouter(func(c *gin.Context) {
dst := &sampleBody{Tag: "preset"}
if !BindAndValidateInto(c, dst) {
t.Fatal("expected ok=true; got false")
}
if dst.Tag != "inbound-443" {
t.Fatalf("expected payload Tag to overwrite preset; got %q", dst.Tag)
}
if dst.Port != 443 {
t.Fatalf("expected Port=443; got %d", dst.Port)
}
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/submit",
strings.NewReader(`{"port":443,"protocol":"trojan","tag":"inbound-443"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
}
func TestBindJSONAndValidate_RejectsFormEncodedBody(t *testing.T) {
r := newRouter(func(c *gin.Context) {
if _, ok := BindJSONAndValidate[sampleBody](c); ok {
t.Fatal("expected ok=false for form-encoded request to a JSON-only endpoint")
}
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/submit",
strings.NewReader("port=443&protocol=vless"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.ServeHTTP(rec, req)
if msg := decodeMsg(t, rec.Body.String()); msg.Success {
t.Fatalf("expected Success=false; got %+v", msg)
}
}
func payloadFromObj(obj any) (ValidationPayload, error) {
raw, err := json.Marshal(obj)
if err != nil {
return ValidationPayload{}, err
}
var payload ValidationPayload
if err := json.Unmarshal(raw, &payload); err != nil {
return ValidationPayload{}, err
}
return payload, nil
}