feat(12-01): add Laravel request validation to lagoon
- lagoon.ValidateRequest ports Laravel 9 request validation: wildcard expansion, implicit-rule stop, bail, sometimes/nullable/blank skipping, size messages split by type and character-counted string lengths - ParseRules, In, CustomRule, UploadedFile and ErrorKeys for rule tables - pl/en lagoon::validation catalogs ported verbatim from WinterCMS - lagoon.Validate answers a numeric range failure with the bound that failed (min, max or numeric between) instead of always max
This commit is contained in:
@@ -17,7 +17,8 @@ Postgres data layer: the shared GORM connection, per-plugin migrations, model he
|
||||
- After-commit work: `lagoon.Transaction` runs a function in a transaction and then the callbacks registered with `lagoon.AfterCommit`, in order, only after the commit succeeds; a nested `lagoon.Transaction` is a savepoint whose callbacks are dropped with it when it fails. A nested `lagoon.Transaction` must be given the outer transaction's handle: given a root handle it returns an error without running its function, rather than open an independent transaction whose callbacks would wait on the outer one. A single-statement write for which GORM opens its own implicit transaction runs its callbacks from `lagoon:after_commit` once GORM commits, and never when the write fails. A callback registered inside a foreign plain GORM transaction is unsafe because Lagoon cannot observe its commit, so `lagoon.AfterCommit` warns and skips it. Outside a transaction, callbacks run immediately. The handle a supported callback receives always has an empty statement on the connection its work belongs to. A panicking callback is logged and never turns a committed write into an error.
|
||||
- Per-plugin migrations: `lagoon.Migrate` runs the framework's `system_files` set (`attach.Migrations`), backend admin identity set (`lagoon.BackendAdminMigrations`) and job-queue set (`lagoon.QueueMigrations`: River's schema pinned at `lagoon.RiverSchemaVersion`, then the `lagoon.JobsTable` record table, under the `lagoon.QueueHistoryID` history), then every `pact.HasMigrations` set in plugin activation order, each in its own `summer_migrations_<plugin_id>` history table (`lagoon.HistoryTableName`). `lagoon.RollbackLast` and `lagoon.Status` cover rollback and history.
|
||||
- Mass assignment: `lagoon.Fill` copies only allow-listed keys onto a model by GORM column name and silently drops the rest, logging each dropped key once outside production. A `json.Number` (from a decoder using `UseNumber`) fills integer, unsigned and float fields. A value that does not fit its column (a fraction, an exponent or an overflow for an integer field, or a value of the wrong type) is a `lagoon.FillTypeError` naming the key, so a caller can answer it as a validation failure on that field. `lagoon.HasFillable` and `lagoon.HasHidden` are the Go forms of `$fillable` and `$hidden`.
|
||||
- Validation: `lagoon.Validate` accepts Laravel-style rule strings (`required`, `nullable`, `integer`, `numeric`, `between`, `min`, `max`, `in`, `unique`, `boolean`, `email`, `confirmed`, `different`, `mimes`) and returns a field-to-messages map, translated through phrasebook when a translator is given. Unknown rule tokens are an error.
|
||||
- Validation: `lagoon.Validate` accepts Laravel-style rule strings (`required`, `nullable`, `integer`, `numeric`, `between`, `min`, `max`, `in`, `unique`, `boolean`, `email`, `confirmed`, `different`, `mimes`) and returns a field-to-messages map, translated through phrasebook when a translator is given. Unknown rule tokens are an error. A failed numeric range reports the bound that failed: the `min` message below the lower bound, the `max` message above the upper one, and the numeric `between` message when the bound came from `between`.
|
||||
- Request validation: `lagoon.ValidateRequest` reproduces Laravel 9 request validation for ported API endpoints, so a 422 body matches the PHP one message for message. It takes the decoded input and an ordered `lagoon.RequestRule` table (attribute names may hold `*` wildcards, expanded against the input to `posts.0.title`), runs the rules of each attribute in order and stops an attribute after a failed implicit rule (`required`, `present`, `filled`, `accepted`) or, under `bail`, after any failure. A non-implicit rule is skipped for an absent attribute, a blank string, a null value under `nullable` and an absent key under `sometimes`. Supported rules: `required`, `present`, `filled`, `accepted`, `nullable`, `sometimes`, `bail`, `array`, `string`, `integer`, `numeric`, `boolean`, `email` (PHP `FILTER_VALIDATE_EMAIL`, WinterCMS's default), `url`, `date`, `after`, `after_or_equal`, `before`, `before_or_equal` (a date, a relative word such as `tomorrow`, or another field), `exists:table,column`, `regex`, `not_regex`, `in`, `not_in`, `file`, `image`, `mimes`, `min`, `max`, `size` and `between`, plus closure rules built with `lagoon.CustomRule`. The size rules compare the number under `numeric` or `integer` (exactly, as decimals), the element count of an array, kilobytes of a `lagoon.UploadedFile`, and otherwise the length in characters, and pick the matching message. Messages come from the `lagoon::validation` catalog in the request locale; `lagoon.ErrorKeys` gives the attribute order of PHP's message bag.
|
||||
- Safe ordering: `lagoon.OrderBy` appends an ORDER BY only for an allow-listed column and an `asc` or `desc` direction, and `lagoon.Collate` adds a validated `COLLATE` clause for language-specific text order (for example the ICU collation `pl-x-icu`); lagoon puts no requirement on the database's default locale.
|
||||
- Pagination: `lagoon.Paginate` builds a `lagoon.Page` with `data` and `meta` (`current_page`, `last_page`, `per_page`, `total`).
|
||||
- Column types: `lagoon.Encrypted` stores AES-256-GCM ciphertext under a key derived from `app.key`, decrypts with previous keys during rotation, and always redacts itself in JSON and string output; `lagoon.Jsonable` stores JSON as TEXT and keeps SQL NULL distinct from an empty value.
|
||||
@@ -135,6 +136,14 @@ func (p *Plugin) Migrations() []*gormigrate.Migration {
|
||||
| `lagoon.Fill` | Allow-listed mass assignment by column name. |
|
||||
| `lagoon.FillTypeError` | Returned by `lagoon.Fill` when a requested value does not fit its column; `Key` names the column. |
|
||||
| `lagoon.Validate` | Laravel-style rule validation with a `unique` database check. |
|
||||
| `lagoon.ValidateRequest` | Laravel 9 request validation of decoded input against an ordered rule table; returns Laravel's errors object. |
|
||||
| `lagoon.RequestRule` | One attribute of a request rule table: `Field` (wildcards allowed) and its ordered `Rules`. |
|
||||
| `lagoon.Rule` | One parsed rule; `lagoon.Rule.Name` and `lagoon.Rule.Args` describe it. |
|
||||
| `lagoon.ParseRules` | Parses a Laravel rule string into rules; keeps `regex:` patterns whole and panics on an unknown rule or an invalid pattern, so rule tables fail at boot. |
|
||||
| `lagoon.In` | The `in` rule from a list of values, for values that contain commas or quotes (Laravel's `Rule::in`). |
|
||||
| `lagoon.CustomRule` | A closure rule; its failure message is used as written. |
|
||||
| `lagoon.UploadedFile` | An uploaded file for the file rules; `lagoon.UploadedFileFromHeader` adapts a `multipart.FileHeader`. |
|
||||
| `lagoon.ErrorKeys` | The attributes of an errors object in Laravel's order for a rule table. |
|
||||
| `lagoon.OrderBy` | Allow-listed ORDER BY, with an optional `lagoon.Collate`. |
|
||||
| `lagoon.Collate` | Order option that sorts the column with a named PostgreSQL collation; the name is validated and quoted. |
|
||||
| `lagoon.OrderOption` | Option type accepted by `lagoon.OrderBy`. |
|
||||
|
||||
@@ -146,8 +146,8 @@ func validateField(ctx context.Context, tx *gorm.DB, model any, field, rule stri
|
||||
}
|
||||
return []string{validateMessage(ctx, tr, ruleName, field, nil)}, nil
|
||||
}
|
||||
if !moneyInRange(s, rangeMin, rangeMax) {
|
||||
return []string{validateMessage(ctx, tr, "max", field, map[string]string{"max": rangeMax, "min": rangeMin})}, nil
|
||||
if msg, failed := numericRangeMessage(ctx, tr, field, s, rangeMin, rangeMax, minArg == "" && betweenMin != "", maxArg == "" && betweenMax != ""); failed {
|
||||
return []string{msg}, nil
|
||||
}
|
||||
tags = withoutTag(tags, "numeric")
|
||||
}
|
||||
@@ -202,6 +202,38 @@ func validateField(ctx context.Context, tx *gorm.DB, model any, field, rule stri
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// numericRangeMessage checks a numeric value against its bounds and answers
|
||||
// a failure with the message of the bound that failed: min below the lower
|
||||
// bound, max above the upper, and Laravel's numeric between message when
|
||||
// that bound came from between.
|
||||
func numericRangeMessage(ctx context.Context, tr *phrasebook.Translator, field, val, lo, hi string, loFromBetween, hiFromBetween bool) (string, bool) {
|
||||
r := new(big.Rat)
|
||||
if _, ok := r.SetString(val); !ok {
|
||||
return validateMessage(ctx, tr, "max", field, map[string]string{"max": hi, "min": lo}), true
|
||||
}
|
||||
below, above := false, false
|
||||
if lo != "" {
|
||||
if m, ok := new(big.Rat).SetString(lo); ok && r.Cmp(m) < 0 {
|
||||
below = true
|
||||
}
|
||||
}
|
||||
if hi != "" {
|
||||
if m, ok := new(big.Rat).SetString(hi); ok && r.Cmp(m) > 0 {
|
||||
above = true
|
||||
}
|
||||
}
|
||||
params := map[string]string{"min": lo, "max": hi}
|
||||
switch {
|
||||
case (below && loFromBetween) || (above && hiFromBetween):
|
||||
return validateMessage(ctx, tr, "between.numeric", field, params), true
|
||||
case below:
|
||||
return validateMessage(ctx, tr, "min", field, params), true
|
||||
case above:
|
||||
return validateMessage(ctx, tr, "max", field, params), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func splitRule(rule string) []string {
|
||||
var out []string
|
||||
for _, p := range strings.Split(rule, "|") {
|
||||
@@ -307,33 +339,6 @@ func numericString(val any) (string, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func moneyInRange(val any, min, max string) bool {
|
||||
s, ok := numericString(val)
|
||||
if !ok {
|
||||
s = strings.TrimSpace(fmt.Sprint(val))
|
||||
if s == "" || s == "<nil>" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
r := new(big.Rat)
|
||||
if _, ok := r.SetString(s); !ok {
|
||||
return false
|
||||
}
|
||||
if min != "" {
|
||||
m := new(big.Rat)
|
||||
if _, ok := m.SetString(min); ok && r.Cmp(m) < 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if max != "" {
|
||||
m := new(big.Rat)
|
||||
if _, ok := m.SetString(max); ok && r.Cmp(m) > 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func uniqueOK(tx *gorm.DB, model any, table, column string, val any) (bool, error) {
|
||||
if tx == nil {
|
||||
return false, fmt.Errorf("lagoon: unique:%s requires a database handle", table)
|
||||
@@ -405,6 +410,9 @@ func validateMessage(ctx context.Context, tr *phrasebook.Translator, rule, field
|
||||
}
|
||||
params["attribute"] = laravelAttribute(field)
|
||||
key := "lagoon::validate." + rule
|
||||
if rule == "between.numeric" {
|
||||
key = "lagoon::validation.between.numeric"
|
||||
}
|
||||
if tr != nil {
|
||||
s := tr.Get(ctx, key, params)
|
||||
if s != "" && s != key {
|
||||
@@ -437,6 +445,8 @@ func validateMessage(ctx context.Context, tr *phrasebook.Translator, rule, field
|
||||
return "The " + attr + " must be a file of the allowed types."
|
||||
case "between":
|
||||
return "The " + attr + " must be between " + params["min"] + " and " + params["max"] + " characters."
|
||||
case "between.numeric":
|
||||
return "The " + attr + " must be between " + params["min"] + " and " + params["max"] + "."
|
||||
case "boolean":
|
||||
return "The " + attr + " field must be true or false."
|
||||
default:
|
||||
|
||||
732
modules/lagoon/validate_request.go
Normal file
732
modules/lagoon/validate_request.go
Normal file
@@ -0,0 +1,732 @@
|
||||
package lagoon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/textproto"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RequestRule is one attribute of a request rule table: the attribute name,
|
||||
// which may contain `*` wildcard segments (posts.*.title), and its rules in
|
||||
// the order Laravel runs them.
|
||||
type RequestRule struct {
|
||||
Field string
|
||||
Rules []Rule
|
||||
}
|
||||
|
||||
// Rule is one parsed validation rule: a named Laravel rule with its
|
||||
// parameters, or a closure built with CustomRule. Build rules with
|
||||
// ParseRules, In or CustomRule; the zero Rule is ignored.
|
||||
type Rule struct {
|
||||
name string
|
||||
args []string
|
||||
custom func(attribute string, value any) (message string, failed bool)
|
||||
re *compiledRegex
|
||||
}
|
||||
|
||||
// Name returns the snake-case rule name (required, max, regex) or "custom"
|
||||
// for a CustomRule.
|
||||
func (r Rule) Name() string {
|
||||
if r.custom != nil {
|
||||
return "custom"
|
||||
}
|
||||
return r.name
|
||||
}
|
||||
|
||||
// Args returns the rule parameters as written after the colon.
|
||||
func (r Rule) Args() []string {
|
||||
return append([]string(nil), r.args...)
|
||||
}
|
||||
|
||||
// CustomRule wraps a closure rule, the Go form of a PHP `function
|
||||
// ($attribute, $value, $fail)` rule. It runs in its declaration position,
|
||||
// only when the value is present (it is not implicit), and a failure adds
|
||||
// the returned message as written, after the :attribute placeholder is
|
||||
// replaced, exactly as Winter adds a closure's $fail message.
|
||||
func CustomRule(fn func(attribute string, value any) (message string, failed bool)) Rule {
|
||||
if fn == nil {
|
||||
panic("lagoon: CustomRule with a nil func")
|
||||
}
|
||||
return Rule{custom: fn}
|
||||
}
|
||||
|
||||
// In is the Go form of Laravel's Rule::in: the value must equal one of
|
||||
// values. Use it when a value contains a comma or a quote.
|
||||
func In(values ...string) Rule {
|
||||
return Rule{name: "in", args: append([]string(nil), values...)}
|
||||
}
|
||||
|
||||
// UploadedFile is an uploaded file offered to the file rules (file, image,
|
||||
// mimes and the size rules, which measure it in kilobytes). Open returns the
|
||||
// content; the rules read at most the first 512 bytes to detect the type.
|
||||
type UploadedFile struct {
|
||||
Filename string
|
||||
Size int64
|
||||
Header textproto.MIMEHeader
|
||||
Open func() (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
// UploadedFileFromHeader adapts a parsed multipart file part.
|
||||
func UploadedFileFromHeader(fh *multipart.FileHeader) UploadedFile {
|
||||
if fh == nil {
|
||||
return UploadedFile{}
|
||||
}
|
||||
return UploadedFile{
|
||||
Filename: fh.Filename,
|
||||
Size: fh.Size,
|
||||
Header: fh.Header,
|
||||
Open: func() (io.ReadCloser, error) {
|
||||
return fh.Open()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateRequest validates decoded request input with Laravel 9 request
|
||||
// validation semantics and returns Laravel's errors object (attribute to
|
||||
// ordered messages), or nil when the input passes. The error return is for
|
||||
// failures that are not the client's fault (a database error in exists:).
|
||||
//
|
||||
// The semantics follow Illuminate\Validation\Validator: `*` segments expand
|
||||
// against the input (posts.0.title, posts.1.title; a wildcard with nothing to
|
||||
// expand adds no attribute); rules run in order; a non-implicit rule runs only
|
||||
// when the attribute is present and is skipped for a blank string, for null
|
||||
// under nullable, and for an absent key under sometimes; an attribute stops
|
||||
// after a failed implicit rule (required, present, filled, accepted) and,
|
||||
// under bail, after any failure. Messages come from the lagoon::validation
|
||||
// catalog in the request locale; size rules pick the numeric, file, array or
|
||||
// string message by the attribute's type.
|
||||
func ValidateRequest(ctx context.Context, tx *gorm.DB, input map[string]any, rules []RequestRule, tr *phrasebook.Translator) (map[string][]string, error) {
|
||||
v := &requestValidator{ctx: ctx, tx: tx, data: input, tr: tr}
|
||||
if v.data == nil {
|
||||
v.data = map[string]any{}
|
||||
}
|
||||
if err := v.explode(rules); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, attr := range v.order {
|
||||
ruleset := v.rules[attr]
|
||||
for _, rule := range ruleset {
|
||||
if err := v.validateAttribute(attr, rule); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if v.shouldStop(attr) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(v.messages) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return v.messages, nil
|
||||
}
|
||||
|
||||
// ErrorKeys returns the attributes of errs in the order Laravel's message bag
|
||||
// holds them for rules: explicit attributes in declaration order, then the
|
||||
// attributes each wildcard rule expanded to, rule by rule, in input order
|
||||
// (array indexes ascending). Callers that compare a 422 body byte for byte
|
||||
// use it to emit the errors object in PHP's key order.
|
||||
func ErrorKeys(errs map[string][]string, rules []RequestRule) []string {
|
||||
type ranked struct {
|
||||
key string
|
||||
group int
|
||||
path []string
|
||||
}
|
||||
explicit := map[string]int{}
|
||||
for i, rr := range rules {
|
||||
if !strings.Contains(rr.Field, "*") {
|
||||
if _, ok := explicit[rr.Field]; !ok {
|
||||
explicit[rr.Field] = i
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]ranked, 0, len(errs))
|
||||
for key := range errs {
|
||||
group := len(rules) * 2
|
||||
if i, ok := explicit[key]; ok {
|
||||
group = i
|
||||
} else {
|
||||
for i, rr := range rules {
|
||||
if strings.Contains(rr.Field, "*") && wildcardMatches(rr.Field, key) {
|
||||
group = len(rules) + i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, ranked{key: key, group: group, path: strings.Split(key, ".")})
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].group != out[j].group {
|
||||
return out[i].group < out[j].group
|
||||
}
|
||||
return pathLess(out[i].path, out[j].path)
|
||||
})
|
||||
keys := make([]string, len(out))
|
||||
for i, r := range out {
|
||||
keys[i] = r.key
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func pathLess(a, b []string) bool {
|
||||
for i := 0; i < len(a) && i < len(b); i++ {
|
||||
if a[i] == b[i] {
|
||||
continue
|
||||
}
|
||||
ai, aerr := strconv.Atoi(a[i])
|
||||
bi, berr := strconv.Atoi(b[i])
|
||||
if aerr == nil && berr == nil {
|
||||
return ai < bi
|
||||
}
|
||||
return a[i] < b[i]
|
||||
}
|
||||
return len(a) < len(b)
|
||||
}
|
||||
|
||||
// wildcardMatches reports whether key is an expansion of pattern: every `*`
|
||||
// stands for one non-empty segment.
|
||||
func wildcardMatches(pattern, key string) bool {
|
||||
ps := strings.Split(pattern, ".")
|
||||
ks := strings.Split(key, ".")
|
||||
if len(ps) != len(ks) {
|
||||
return false
|
||||
}
|
||||
for i := range ps {
|
||||
if ps[i] == "*" {
|
||||
if ks[i] == "" {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ps[i] != ks[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type requestValidator struct {
|
||||
ctx context.Context
|
||||
tx *gorm.DB
|
||||
data map[string]any
|
||||
tr *phrasebook.Translator
|
||||
order []string
|
||||
rules map[string][]Rule
|
||||
primary map[string]string // expanded attribute -> wildcard pattern
|
||||
messages map[string][]string
|
||||
failed map[string]map[string]bool
|
||||
}
|
||||
|
||||
// explode builds the attribute order Laravel's ValidationRuleParser gives:
|
||||
// explicit attributes keep their declaration position (a later explicit
|
||||
// declaration of an expanded key replaces its rules), and wildcard
|
||||
// expansions are appended in expansion order.
|
||||
func (v *requestValidator) explode(rules []RequestRule) error {
|
||||
v.rules = map[string][]Rule{}
|
||||
v.primary = map[string]string{}
|
||||
seen := map[string]bool{}
|
||||
for _, rr := range rules {
|
||||
if rr.Field == "" {
|
||||
return fmt.Errorf("lagoon: request rule with an empty field")
|
||||
}
|
||||
if seen[rr.Field] {
|
||||
return fmt.Errorf("lagoon: duplicate request rule field %q", rr.Field)
|
||||
}
|
||||
seen[rr.Field] = true
|
||||
v.order = append(v.order, rr.Field)
|
||||
v.rules[rr.Field] = cleanRules(rr.Rules)
|
||||
}
|
||||
for _, rr := range rules {
|
||||
if !strings.Contains(rr.Field, "*") {
|
||||
v.rules[rr.Field] = cleanRules(rr.Rules)
|
||||
continue
|
||||
}
|
||||
for _, key := range expandWildcard(v.data, rr.Field) {
|
||||
if _, ok := v.rules[key]; ok {
|
||||
v.rules[key] = append(v.rules[key], cleanRules(rr.Rules)...)
|
||||
} else {
|
||||
v.order = append(v.order, key)
|
||||
v.rules[key] = cleanRules(rr.Rules)
|
||||
}
|
||||
if _, ok := v.primary[key]; !ok {
|
||||
v.primary[key] = rr.Field
|
||||
}
|
||||
}
|
||||
delete(v.rules, rr.Field)
|
||||
v.order = removeString(v.order, rr.Field)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanRules(rules []Rule) []Rule {
|
||||
out := make([]Rule, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
if r.name == "" && r.custom == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func removeString(list []string, s string) []string {
|
||||
out := list[:0]
|
||||
for _, x := range list {
|
||||
if x != s {
|
||||
out = append(out, x)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type expandedKey struct {
|
||||
key string
|
||||
leaf bool
|
||||
}
|
||||
|
||||
// expandWildcard lists the concrete attributes a wildcard pattern stands for
|
||||
// in data, in the order Laravel's ValidationData gathers them: attributes that
|
||||
// are leaves of the dotted input first, then the rest, each in input order.
|
||||
// Literal segments after the last `*` always produce the attribute (its value
|
||||
// may be missing); a missing or scalar node before a `*` produces nothing.
|
||||
func expandWildcard(data map[string]any, pattern string) []string {
|
||||
segs := strings.Split(pattern, ".")
|
||||
last := -1
|
||||
for i, s := range segs {
|
||||
if s == "*" {
|
||||
last = i
|
||||
}
|
||||
}
|
||||
var found []expandedKey
|
||||
var walk func(node any, present bool, i int, prefix string)
|
||||
walk = func(node any, present bool, i int, prefix string) {
|
||||
if i == len(segs) {
|
||||
found = append(found, expandedKey{key: prefix, leaf: !present || isDotLeaf(node)})
|
||||
return
|
||||
}
|
||||
seg := segs[i]
|
||||
if seg == "*" {
|
||||
for _, ch := range children(node) {
|
||||
walk(ch.value, true, i+1, joinPath(prefix, ch.key))
|
||||
}
|
||||
return
|
||||
}
|
||||
child, ok := childOf(node, seg)
|
||||
if i > last {
|
||||
walk(child, ok, i+1, joinPath(prefix, seg))
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
walk(child, true, i+1, joinPath(prefix, seg))
|
||||
}
|
||||
walk(data, true, 0, "")
|
||||
out := make([]string, 0, len(found))
|
||||
seen := map[string]bool{}
|
||||
for _, pass := range []bool{true, false} {
|
||||
for _, f := range found {
|
||||
if f.leaf != pass || seen[f.key] {
|
||||
continue
|
||||
}
|
||||
seen[f.key] = true
|
||||
out = append(out, f.key)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func joinPath(prefix, seg string) string {
|
||||
if prefix == "" {
|
||||
return seg
|
||||
}
|
||||
return prefix + "." + seg
|
||||
}
|
||||
|
||||
// isDotLeaf reports whether Arr::dot would emit the value as one key: any
|
||||
// scalar and any empty array.
|
||||
func isDotLeaf(v any) bool {
|
||||
return len(children(v)) == 0
|
||||
}
|
||||
|
||||
type childEntry struct {
|
||||
key string
|
||||
value any
|
||||
}
|
||||
|
||||
// children lists an array's elements or a map's entries. Go maps carry no
|
||||
// insertion order, so map entries come sorted by key.
|
||||
func children(v any) []childEntry {
|
||||
switch t := v.(type) {
|
||||
case []any:
|
||||
out := make([]childEntry, len(t))
|
||||
for i, x := range t {
|
||||
out[i] = childEntry{key: strconv.Itoa(i), value: x}
|
||||
}
|
||||
return out
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(t))
|
||||
for k := range t {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
out := make([]childEntry, len(keys))
|
||||
for i, k := range keys {
|
||||
out[i] = childEntry{key: k, value: t[k]}
|
||||
}
|
||||
return out
|
||||
case []string:
|
||||
out := make([]childEntry, len(t))
|
||||
for i, x := range t {
|
||||
out[i] = childEntry{key: strconv.Itoa(i), value: x}
|
||||
}
|
||||
return out
|
||||
case []map[string]any:
|
||||
out := make([]childEntry, len(t))
|
||||
for i, x := range t {
|
||||
out[i] = childEntry{key: strconv.Itoa(i), value: x}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func childOf(v any, seg string) (any, bool) {
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
x, ok := t[seg]
|
||||
return x, ok
|
||||
case []any:
|
||||
i, err := strconv.Atoi(seg)
|
||||
if err != nil || i < 0 || i >= len(t) || strconv.Itoa(i) != seg {
|
||||
return nil, false
|
||||
}
|
||||
return t[i], true
|
||||
case []string:
|
||||
i, err := strconv.Atoi(seg)
|
||||
if err != nil || i < 0 || i >= len(t) || strconv.Itoa(i) != seg {
|
||||
return nil, false
|
||||
}
|
||||
return t[i], true
|
||||
case []map[string]any:
|
||||
i, err := strconv.Atoi(seg)
|
||||
if err != nil || i < 0 || i >= len(t) || strconv.Itoa(i) != seg {
|
||||
return nil, false
|
||||
}
|
||||
return t[i], true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// lookup is Arr::get/Arr::has over the dotted attribute.
|
||||
func (v *requestValidator) lookup(attr string) (any, bool) {
|
||||
var node any = v.data
|
||||
for _, seg := range strings.Split(attr, ".") {
|
||||
x, ok := childOf(node, seg)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
node = x
|
||||
}
|
||||
return node, true
|
||||
}
|
||||
|
||||
func (v *requestValidator) hasRule(attr string, names ...string) bool {
|
||||
for _, r := range v.rules[attr] {
|
||||
for _, n := range names {
|
||||
if r.custom == nil && r.name == n {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (v *requestValidator) validateAttribute(attr string, rule Rule) error {
|
||||
value, present := v.lookup(attr)
|
||||
if !v.isValidatable(rule, attr, value, present) {
|
||||
return nil
|
||||
}
|
||||
if rule.custom != nil {
|
||||
msg, failed := rule.custom(attr, value)
|
||||
if failed {
|
||||
v.addMessage(attr, v.replaceAttribute(msg, attr))
|
||||
v.markFailed(attr, "custom")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
ok, err := v.passes(rule, attr, value, present)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
v.addFailure(attr, rule, value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *requestValidator) isValidatable(rule Rule, attr string, value any, present bool) bool {
|
||||
implicit := rule.isImplicit()
|
||||
// presentOrRuleIsImplicit
|
||||
if s, ok := value.(string); ok && phpTrim(s) == "" && present {
|
||||
if !implicit {
|
||||
return false
|
||||
}
|
||||
} else if !present && !implicit {
|
||||
return false
|
||||
}
|
||||
// passesOptionalCheck
|
||||
if v.hasRule(attr, "sometimes") && !present {
|
||||
return false
|
||||
}
|
||||
// isNotNullIfMarkedAsNullable
|
||||
if !implicit && v.hasRule(attr, "nullable") && present && value == nil {
|
||||
return false
|
||||
}
|
||||
// hasNotFailedPreviousRuleIfPresenceRule
|
||||
if rule.custom == nil && (rule.name == "exists" || rule.name == "unique") && len(v.messages[attr]) > 0 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (v *requestValidator) shouldStop(attr string) bool {
|
||||
if v.hasRule(attr, "bail") {
|
||||
return len(v.messages[attr]) > 0
|
||||
}
|
||||
if !v.hasRule(attr, implicitRuleNames...) {
|
||||
return false
|
||||
}
|
||||
for name := range v.failed[attr] {
|
||||
if isImplicitName(name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (v *requestValidator) addMessage(attr, msg string) {
|
||||
if v.messages == nil {
|
||||
v.messages = map[string][]string{}
|
||||
}
|
||||
v.messages[attr] = append(v.messages[attr], msg)
|
||||
}
|
||||
|
||||
func (v *requestValidator) markFailed(attr, rule string) {
|
||||
if v.failed == nil {
|
||||
v.failed = map[string]map[string]bool{}
|
||||
}
|
||||
if v.failed[attr] == nil {
|
||||
v.failed[attr] = map[string]bool{}
|
||||
}
|
||||
v.failed[attr][rule] = true
|
||||
}
|
||||
|
||||
func (v *requestValidator) addFailure(attr string, rule Rule, value any) {
|
||||
msg := v.message(attr, rule.name)
|
||||
msg = v.replaceAttribute(msg, attr)
|
||||
msg = replaceInput(msg, value)
|
||||
msg = replaceIndexes(msg, attr)
|
||||
msg = v.replaceRule(msg, attr, rule)
|
||||
v.addMessage(attr, msg)
|
||||
v.markFailed(attr, rule.name)
|
||||
}
|
||||
|
||||
const catalogPrefix = "lagoon::validation."
|
||||
|
||||
// message is Winter's FormatsMessages::getMessage: a custom line for the
|
||||
// attribute and rule, then the typed line of a size rule, then the rule line.
|
||||
func (v *requestValidator) message(attr, rule string) string {
|
||||
if s, ok := v.line("custom." + attr + "." + rule); ok {
|
||||
return s
|
||||
}
|
||||
if sizeRuleNames[rule] {
|
||||
if s, ok := v.line(rule + "." + v.attributeType(attr)); ok {
|
||||
return s
|
||||
}
|
||||
return "validation." + rule + "." + v.attributeType(attr)
|
||||
}
|
||||
if s, ok := v.line(rule); ok {
|
||||
return s
|
||||
}
|
||||
return "validation." + rule
|
||||
}
|
||||
|
||||
func (v *requestValidator) line(key string) (string, bool) {
|
||||
if v.tr == nil {
|
||||
return "", false
|
||||
}
|
||||
full := catalogPrefix + key
|
||||
if !v.tr.Has(full) {
|
||||
return "", false
|
||||
}
|
||||
s := v.tr.Get(v.ctx, full, nil)
|
||||
if s == full {
|
||||
return "", false
|
||||
}
|
||||
return s, true
|
||||
}
|
||||
|
||||
func (v *requestValidator) attributeType(attr string) string {
|
||||
switch {
|
||||
case v.hasRule(attr, numericRuleNames...):
|
||||
return "numeric"
|
||||
case v.hasRule(attr, "array"):
|
||||
return "array"
|
||||
}
|
||||
if val, ok := v.lookup(attr); ok {
|
||||
if _, isFile := asUploadedFile(val); isFile {
|
||||
return "file"
|
||||
}
|
||||
}
|
||||
return "string"
|
||||
}
|
||||
|
||||
// displayableAttribute is Laravel's getDisplayableAttribute: a catalog
|
||||
// attribute name for the attribute or its wildcard pattern, the raw name of
|
||||
// an expanded attribute, else the snake-cased name with spaces.
|
||||
func (v *requestValidator) displayableAttribute(attr string) string {
|
||||
names := []string{attr}
|
||||
primary, expanded := v.primary[attr]
|
||||
if expanded && primary != attr {
|
||||
names = append(names, primary)
|
||||
}
|
||||
for _, n := range names {
|
||||
if s, ok := v.line("attributes." + n); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
if expanded {
|
||||
return attr
|
||||
}
|
||||
return strings.ReplaceAll(laravelSnake(attr), "_", " ")
|
||||
}
|
||||
|
||||
func (v *requestValidator) replaceAttribute(msg, attr string) string {
|
||||
name := v.displayableAttribute(attr)
|
||||
return strings.NewReplacer(
|
||||
":attribute", name,
|
||||
":ATTRIBUTE", strings.ToUpper(name),
|
||||
":Attribute", phpUcfirst(name),
|
||||
).Replace(msg)
|
||||
}
|
||||
|
||||
func (v *requestValidator) replaceRule(msg, attr string, rule Rule) string {
|
||||
switch rule.name {
|
||||
case "between":
|
||||
return strings.NewReplacer(":min", argAt(rule.args, 0), ":max", argAt(rule.args, 1)).Replace(msg)
|
||||
case "min":
|
||||
return strings.ReplaceAll(msg, ":min", argAt(rule.args, 0))
|
||||
case "max":
|
||||
return strings.ReplaceAll(msg, ":max", argAt(rule.args, 0))
|
||||
case "size":
|
||||
return strings.ReplaceAll(msg, ":size", argAt(rule.args, 0))
|
||||
case "in", "not_in", "mimes":
|
||||
return strings.ReplaceAll(msg, ":values", strings.Join(rule.args, ", "))
|
||||
case "after", "after_or_equal", "before", "before_or_equal":
|
||||
arg := argAt(rule.args, 0)
|
||||
if _, ok := parseDateArg(arg); !ok {
|
||||
return strings.ReplaceAll(msg, ":date", v.displayableAttribute(arg))
|
||||
}
|
||||
return strings.ReplaceAll(msg, ":date", arg)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func argAt(args []string, i int) string {
|
||||
if i < len(args) {
|
||||
return args[i]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// replaceInput replaces :input with a scalar value, as Laravel does.
|
||||
func replaceInput(msg string, value any) string {
|
||||
if !strings.Contains(msg, ":input") {
|
||||
return msg
|
||||
}
|
||||
s, ok := phpScalarString(value)
|
||||
if !ok {
|
||||
return msg
|
||||
}
|
||||
return strings.ReplaceAll(msg, ":input", s)
|
||||
}
|
||||
|
||||
// replaceIndexes replaces :index and :position (zero- and one-based) with
|
||||
// the first numeric segment of the attribute.
|
||||
func replaceIndexes(msg, attr string) string {
|
||||
if !strings.Contains(msg, ":index") && !strings.Contains(msg, ":position") {
|
||||
return msg
|
||||
}
|
||||
for _, seg := range strings.Split(attr, ".") {
|
||||
if n, err := strconv.Atoi(seg); err == nil {
|
||||
msg = strings.ReplaceAll(msg, ":index", strconv.Itoa(n))
|
||||
msg = strings.ReplaceAll(msg, ":position", strconv.Itoa(n+1))
|
||||
break
|
||||
}
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// laravelSnake ports Str::snake with the underscore delimiter.
|
||||
func laravelSnake(s string) string {
|
||||
if isCtypeLower(s) {
|
||||
return s
|
||||
}
|
||||
s = phpUcwords(s)
|
||||
var b strings.Builder
|
||||
first := true
|
||||
for _, r := range s {
|
||||
if r == ' ' || r == '\t' || r == '\n' || r == '\r' || r == '\f' || r == '\v' {
|
||||
continue
|
||||
}
|
||||
if !first && r >= 'A' && r <= 'Z' {
|
||||
b.WriteByte('_')
|
||||
}
|
||||
b.WriteRune(r)
|
||||
first = false
|
||||
}
|
||||
return strings.ToLower(b.String())
|
||||
}
|
||||
|
||||
func isCtypeLower(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] < 'a' || s[i] > 'z' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func phpUcwords(s string) string {
|
||||
b := []byte(s)
|
||||
start := true
|
||||
for i, c := range b {
|
||||
if start && c >= 'a' && c <= 'z' {
|
||||
b[i] = c - 'a' + 'A'
|
||||
}
|
||||
start = c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == '\f' || c == '\v'
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// phpUcfirst ports Str::ucfirst, which upper-cases the first character
|
||||
// multibyte-safely.
|
||||
func phpUcfirst(s string) string {
|
||||
r, size := utf8.DecodeRuneInString(s)
|
||||
if size == 0 || r == utf8.RuneError {
|
||||
return s
|
||||
}
|
||||
return strings.ToUpper(string(r)) + s[size:]
|
||||
}
|
||||
365
modules/lagoon/validate_request_test.go
Normal file
365
modules/lagoon/validate_request_test.go
Normal file
@@ -0,0 +1,365 @@
|
||||
package lagoon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"git.golem15.com/golem15/summercms/modules/towel"
|
||||
)
|
||||
|
||||
// requestTranslator loads the framework's lagoon::validation and
|
||||
// lagoon::validate catalogs from the phrasebook package directory.
|
||||
func requestTranslator(t *testing.T) *phrasebook.Translator {
|
||||
t.Helper()
|
||||
files := fstest.MapFS{}
|
||||
for _, loc := range []string{"en", "pl"} {
|
||||
for _, group := range []string{"validation", "validate"} {
|
||||
rel := filepath.Join("lang", loc, group+".yaml")
|
||||
raw, err := os.ReadFile(filepath.Join("..", "phrasebook", rel))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files[filepath.ToSlash(rel)] = &fstest.MapFile{Data: raw}
|
||||
}
|
||||
}
|
||||
cat := phrasebook.NewCatalog()
|
||||
if err := cat.Load("lagoon", files); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return phrasebook.NewTranslator(cat, phrasebook.Options{Locale: "en", Fallback: "en"})
|
||||
}
|
||||
|
||||
func inLocale(locale string) context.Context {
|
||||
return towel.WithLocale(context.Background(), locale)
|
||||
}
|
||||
|
||||
func mustValidate(t *testing.T, ctx context.Context, input map[string]any, rules []RequestRule) map[string][]string {
|
||||
t.Helper()
|
||||
errs, err := ValidateRequest(ctx, nil, input, rules, requestTranslator(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func TestValidateRequestEmptyArrayStopsAtRequired(t *testing.T) {
|
||||
rules := []RequestRule{{Field: "posts", Rules: ParseRules("required|array|min:1")}}
|
||||
input := map[string]any{"posts": []any{}}
|
||||
got := mustValidate(t, inLocale("pl"), input, rules)
|
||||
want := map[string][]string{"posts": {"Pole posts jest wymagane."}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("pl = %#v, want %#v", got, want)
|
||||
}
|
||||
got = mustValidate(t, inLocale("en"), input, rules)
|
||||
want = map[string][]string{"posts": {"The posts field is required."}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("en = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequestWildcardNamesIndexedAttribute(t *testing.T) {
|
||||
rules := []RequestRule{
|
||||
{Field: "posts", Rules: ParseRules("required|array|min:1")},
|
||||
{Field: "posts.*.title", Rules: ParseRules("required|string|max:255")},
|
||||
{Field: "posts.*.tags.*", Rules: ParseRules("required")},
|
||||
}
|
||||
input := map[string]any{"posts": []any{
|
||||
map[string]any{"title": "Go", "tags": []any{"a", ""}},
|
||||
map[string]any{"tags": []any{}},
|
||||
"not an object",
|
||||
}}
|
||||
got := mustValidate(t, inLocale("pl"), input, rules)
|
||||
want := map[string][]string{
|
||||
"posts.1.title": {"Pole posts.1.title jest wymagane."},
|
||||
"posts.2.title": {"Pole posts.2.title jest wymagane."},
|
||||
"posts.0.tags.1": {"Pole posts.0.tags.1 jest wymagane."},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %#v, want %#v", got, want)
|
||||
}
|
||||
keys := ErrorKeys(got, rules)
|
||||
wantKeys := []string{"posts.1.title", "posts.2.title", "posts.0.tags.1"}
|
||||
if !reflect.DeepEqual(keys, wantKeys) {
|
||||
t.Fatalf("ErrorKeys = %v, want %v", keys, wantKeys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequestWildcardWithoutParentAddsNothing(t *testing.T) {
|
||||
// Laravel drops a wildcard rule that has nothing to expand: an absent
|
||||
// posts produces no posts.*.title attribute, so only posts reports.
|
||||
rules := []RequestRule{
|
||||
{Field: "posts", Rules: ParseRules("nullable|array")},
|
||||
{Field: "posts.*.title", Rules: ParseRules("required")},
|
||||
}
|
||||
if got := mustValidate(t, inLocale("en"), map[string]any{}, rules); got != nil {
|
||||
t.Fatalf("got %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequestStringLengthCountsCharacters(t *testing.T) {
|
||||
rules := []RequestRule{{Field: "title", Rules: ParseRules("required|string|max:255")}}
|
||||
ok := strings.Repeat("ą", 255)
|
||||
if got := mustValidate(t, inLocale("pl"), map[string]any{"title": ok}, rules); got != nil {
|
||||
t.Fatalf("255 characters: %v", got)
|
||||
}
|
||||
got := mustValidate(t, inLocale("pl"), map[string]any{"title": ok + "ż"}, rules)
|
||||
want := map[string][]string{"title": {"title nie może być dłuższy niż 255 znaków."}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("256 characters = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequestBetweenIntegerBoundary(t *testing.T) {
|
||||
rules := []RequestRule{{Field: "year", Rules: ParseRules("nullable|integer|between:1889,2100")}}
|
||||
for _, year := range []any{float64(1889), float64(2100), "1889", nil} {
|
||||
if got := mustValidate(t, inLocale("en"), map[string]any{"year": year}, rules); got != nil {
|
||||
t.Fatalf("year %v: %v", year, got)
|
||||
}
|
||||
}
|
||||
for _, year := range []any{float64(1888), float64(2101)} {
|
||||
got := mustValidate(t, inLocale("en"), map[string]any{"year": year}, rules)
|
||||
want := map[string][]string{"year": {"The year must be between 1889 and 2100."}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("year %v = %#v", year, got)
|
||||
}
|
||||
}
|
||||
got := mustValidate(t, inLocale("en"), map[string]any{"year": 1991.5}, rules)
|
||||
want := map[string][]string{"year": {"The year must be an integer."}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("1991.5 = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequestNumericPrecision(t *testing.T) {
|
||||
rules := []RequestRule{{Field: "market_price", Rules: ParseRules("nullable|numeric|min:0|max:999999.9999")}}
|
||||
for _, v := range []any{"999999.9999", 999999.9999, float64(0), "0.0001"} {
|
||||
if got := mustValidate(t, inLocale("en"), map[string]any{"market_price": v}, rules); got != nil {
|
||||
t.Fatalf("%v: %v", v, got)
|
||||
}
|
||||
}
|
||||
got := mustValidate(t, inLocale("en"), map[string]any{"market_price": float64(1000000)}, rules)
|
||||
want := map[string][]string{"market_price": {"The market price may not be greater than 999999.9999."}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("1000000 = %#v", got)
|
||||
}
|
||||
got = mustValidate(t, inLocale("pl"), map[string]any{"market_price": "-0.01"}, rules)
|
||||
want = map[string][]string{"market_price": {"market price musi być nie mniejszy od 0."}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("-0.01 = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequestPolishFallsBackToEnglish(t *testing.T) {
|
||||
restore := requestNow
|
||||
requestNow = func() time.Time { return time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC) }
|
||||
t.Cleanup(func() { requestNow = restore })
|
||||
rules := []RequestRule{{Field: "created_at", Rules: ParseRules("nullable|date|after_or_equal:1900-01-01|before_or_equal:tomorrow")}}
|
||||
for _, v := range []string{"1900-01-01", "2026-10-03", "2026-10-03T00:00:00+00:00"} {
|
||||
if got := mustValidate(t, inLocale("pl"), map[string]any{"created_at": v}, rules); got != nil {
|
||||
t.Fatalf("%s: %v", v, got)
|
||||
}
|
||||
}
|
||||
got := mustValidate(t, inLocale("pl"), map[string]any{"created_at": "1899-12-31"}, rules)
|
||||
want := map[string][]string{"created_at": {"The created at must be a date after or equal to 1900-01-01."}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("1899 = %#v", got)
|
||||
}
|
||||
got = mustValidate(t, inLocale("pl"), map[string]any{"created_at": "2026-10-04"}, rules)
|
||||
want = map[string][]string{"created_at": {"The created at must be a date before or equal to tomorrow."}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("day after tomorrow = %#v", got)
|
||||
}
|
||||
got = mustValidate(t, inLocale("pl"), map[string]any{"created_at": "garbage"}, rules)
|
||||
want = map[string][]string{"created_at": {
|
||||
"created at nie jest prawidłową datą.",
|
||||
"The created at must be a date after or equal to 1900-01-01.",
|
||||
}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("garbage = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequestPresenceSemantics(t *testing.T) {
|
||||
rules := []RequestRule{
|
||||
{Field: "name", Rules: ParseRules("sometimes|required|string|min:1|max:255")},
|
||||
{Field: "notes", Rules: ParseRules("nullable|string")},
|
||||
{Field: "label", Rules: ParseRules("string|max:3")},
|
||||
{Field: "count", Rules: ParseRules("integer")},
|
||||
{Field: "body", Rules: ParseRules("string")},
|
||||
}
|
||||
input := map[string]any{"notes": nil, "label": " ", "count": "", "body": nil}
|
||||
got := mustValidate(t, inLocale("en"), input, rules)
|
||||
want := map[string][]string{"body": {"The body must be a string."}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %#v, want %#v", got, want)
|
||||
}
|
||||
got = mustValidate(t, inLocale("en"), map[string]any{"name": ""}, rules[:1])
|
||||
want = map[string][]string{"name": {"The name field is required."}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("blank name = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequestBailAndOrder(t *testing.T) {
|
||||
rules := []RequestRule{
|
||||
{Field: "code", Rules: ParseRules("string|min:5|regex:/^[a-z]+$/")},
|
||||
{Field: "slug", Rules: ParseRules("bail|string|min:5|regex:/^[a-z]+$/")},
|
||||
}
|
||||
got := mustValidate(t, inLocale("en"), map[string]any{"code": "A1", "slug": "A1"}, rules)
|
||||
want := map[string][]string{
|
||||
"code": {"The code must be at least 5 characters.", "The code format is invalid."},
|
||||
"slug": {"The slug must be at least 5 characters."},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequestCustomRuleMessageVerbatim(t *testing.T) {
|
||||
lines := CustomRule(func(attribute string, value any) (string, bool) {
|
||||
s, _ := value.(string)
|
||||
if strings.Count(s, "\n")+1 > 2 {
|
||||
return "The tracklist text may not have more than 2 lines.", true
|
||||
}
|
||||
return "", false
|
||||
})
|
||||
rules := []RequestRule{{Field: "tracklist_text", Rules: append(ParseRules("nullable|string|max:20000"), lines)}}
|
||||
got := mustValidate(t, inLocale("pl"), map[string]any{"tracklist_text": "a\nb\nc"}, rules)
|
||||
want := map[string][]string{"tracklist_text": {"The tracklist text may not have more than 2 lines."}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %#v", got)
|
||||
}
|
||||
if got := mustValidate(t, inLocale("pl"), map[string]any{"tracklist_text": nil}, rules); got != nil {
|
||||
t.Fatalf("null text: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequestParseRules(t *testing.T) {
|
||||
rs := ParseRules(`nullable|string|regex:/^(a|b),c$/i|in:LP,"EP 7""",CD|max:16`)
|
||||
var names []string
|
||||
for _, r := range rs {
|
||||
names = append(names, r.Name())
|
||||
}
|
||||
if want := []string{"nullable", "string", "regex", "in", "max"}; !reflect.DeepEqual(names, want) {
|
||||
t.Fatalf("names = %v", names)
|
||||
}
|
||||
if got := rs[3].Args(); !reflect.DeepEqual(got, []string{"LP", `EP 7"`, "CD"}) {
|
||||
t.Fatalf("in args = %q", got)
|
||||
}
|
||||
rules := []RequestRule{{Field: "v", Rules: rs}}
|
||||
if got := mustValidate(t, inLocale("en"), map[string]any{"v": "B,c"}, rules); len(got["v"]) != 1 || got["v"][0] != "The selected v is invalid." {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
for _, bad := range []string{"required|nope", "max", "between:1", "regex:/(?<=a)b/", "exists:users;drop,id", "regex:/a/x"} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatalf("ParseRules(%q) did not panic", bad)
|
||||
}
|
||||
}()
|
||||
ParseRules(bad)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequestUploadedFile(t *testing.T) {
|
||||
png := []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR")
|
||||
file := func(name string, size int64, content []byte) UploadedFile {
|
||||
return UploadedFile{Filename: name, Size: size, Open: func() (io.ReadCloser, error) {
|
||||
return io.NopCloser(bytes.NewReader(content)), nil
|
||||
}}
|
||||
}
|
||||
rules := []RequestRule{{Field: "photo", Rules: ParseRules("required|image|mimes:jpg,jpeg,png,gif,webp|max:10240")}}
|
||||
if got := mustValidate(t, inLocale("en"), map[string]any{"photo": file("a.png", 10240*1024, png)}, rules); got != nil {
|
||||
t.Fatalf("10240 KB: %v", got)
|
||||
}
|
||||
got := mustValidate(t, inLocale("en"), map[string]any{"photo": file("a.png", 10240*1024+1, png)}, rules)
|
||||
want := map[string][]string{"photo": {"The photo may not be greater than 10240 kilobytes."}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("over limit = %#v", got)
|
||||
}
|
||||
got = mustValidate(t, inLocale("pl"), map[string]any{"photo": file("a.txt", 10, []byte("hello"))}, rules)
|
||||
want = map[string][]string{"photo": {"photo musi być obrazkiem.", "photo musi być plikiem typu jpg, jpeg, png, gif, webp."}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("text file = %#v", got)
|
||||
}
|
||||
got = mustValidate(t, inLocale("en"), map[string]any{"photo": file("shell.php", 10, png)}, rules)
|
||||
if len(got["photo"]) != 2 {
|
||||
t.Fatalf("php extension = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequestEmailURLBoolean(t *testing.T) {
|
||||
rules := []RequestRule{
|
||||
{Field: "email", Rules: ParseRules("nullable|email")},
|
||||
{Field: "url", Rules: ParseRules("nullable|url")},
|
||||
{Field: "flag", Rules: ParseRules("nullable|boolean")},
|
||||
}
|
||||
pass := []map[string]any{
|
||||
{"email": "jan.kowalski@example.com"},
|
||||
{"email": "a+b@sub.example.co.uk"},
|
||||
{"email": `"quoted"@example.com`},
|
||||
{"email": "x@[127.0.0.1]"},
|
||||
{"url": "https://www.discogs.com/release/1?x=1#y"},
|
||||
{"url": "http://192.168.0.1:8080/a"},
|
||||
{"flag": true}, {"flag": "0"}, {"flag": float64(1)},
|
||||
}
|
||||
for _, in := range pass {
|
||||
if got := mustValidate(t, inLocale("en"), in, rules); got != nil {
|
||||
t.Fatalf("%v: %v", in, got)
|
||||
}
|
||||
}
|
||||
fail := []map[string]any{
|
||||
{"email": "user@localhost"},
|
||||
{"email": "a..b@example.com"},
|
||||
{"email": "zażółć@example.com"},
|
||||
{"email": "a@example.1com"},
|
||||
{"email": strings.Repeat("a", 65) + "@example.com"},
|
||||
{"url": "not a url"},
|
||||
{"url": "javascript:alert(1)"},
|
||||
{"flag": "true"}, {"flag": float64(2)},
|
||||
}
|
||||
for _, in := range fail {
|
||||
if got := mustValidate(t, inLocale("en"), in, rules); got == nil {
|
||||
t.Fatalf("%v must fail", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequestExistsNeedsDatabase(t *testing.T) {
|
||||
rules := []RequestRule{{Field: "genre_id", Rules: ParseRules("nullable|integer|exists:genres,id")}}
|
||||
if _, err := ValidateRequest(context.Background(), nil, map[string]any{"genre_id": float64(3)}, rules, nil); err == nil {
|
||||
t.Fatal("exists without a database handle must be an error")
|
||||
}
|
||||
// A failed integer rule skips exists, as Laravel does for presence rules.
|
||||
errs, err := ValidateRequest(context.Background(), nil, map[string]any{"genre_id": "x"}, rules, nil)
|
||||
if err != nil || len(errs["genre_id"]) != 1 {
|
||||
t.Fatalf("errs %v err %v", errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequestErrorKeysDeclarationOrder(t *testing.T) {
|
||||
rules := []RequestRule{
|
||||
{Field: "tracklist", Rules: ParseRules("nullable|array")},
|
||||
{Field: "tracklist.*.title", Rules: ParseRules("required")},
|
||||
{Field: "name", Rules: ParseRules("required")},
|
||||
}
|
||||
input := map[string]any{"tracklist": []any{
|
||||
map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{},
|
||||
map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{},
|
||||
}}
|
||||
errs := mustValidate(t, inLocale("en"), input, rules)
|
||||
keys := ErrorKeys(errs, rules)
|
||||
if keys[0] != "name" || keys[1] != "tracklist.0.title" || keys[2] != "tracklist.1.title" || keys[11] != "tracklist.10.title" {
|
||||
t.Fatalf("keys = %v", keys)
|
||||
}
|
||||
}
|
||||
1217
modules/lagoon/validate_rules.go
Normal file
1217
modules/lagoon/validate_rules.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -229,3 +229,43 @@ func TestValidateUniqueRespectsDeletedAt(t *testing.T) {
|
||||
t.Fatalf("soft-deleted slug should pass unique: %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNumericRangeMessagePicksFailedBound(t *testing.T) {
|
||||
cases := []struct {
|
||||
rule string
|
||||
val any
|
||||
want string
|
||||
}{
|
||||
{"integer|min:0", -1, "The views must be at least 0."},
|
||||
{"integer|max:10", 11, "The views may not be greater than 10."},
|
||||
{"numeric|min:0|max:10", "-0.5", "The views must be at least 0."},
|
||||
{"numeric|min:0|max:10", "10.5", "The views may not be greater than 10."},
|
||||
{"integer|between:0,10", 11, "The views must be between 0 and 10."},
|
||||
{"integer|between:0,10", -1, "The views must be between 0 and 10."},
|
||||
{"integer|between:0,10|min:2", 1, "The views must be at least 2."},
|
||||
}
|
||||
for _, c := range cases {
|
||||
errs, err := Validate(t.Context(), nil, nil, map[string]string{"views": c.rule}, map[string]any{"views": c.val}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := errs["views"]; len(got) != 1 || got[0] != c.want {
|
||||
t.Fatalf("%s with %v = %v, want %q", c.rule, c.val, got, c.want)
|
||||
}
|
||||
}
|
||||
errs, err := Validate(t.Context(), nil, nil, map[string]string{"views": "integer|between:0,10"}, map[string]any{"views": 5}, nil)
|
||||
if err != nil || errs != nil {
|
||||
t.Fatalf("in range: %v %v", errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNumericRangeMessageTranslated(t *testing.T) {
|
||||
tr := requestTranslator(t)
|
||||
errs, err := Validate(inLocale("pl"), nil, nil, map[string]string{"year": "integer|between:1889,2100"}, map[string]any{"year": 1700}, tr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := errs["year"]; len(got) != 1 || got[0] != "year musi zawierać się w granicach 1889 - 2100." {
|
||||
t.Fatalf("pl between = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user