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:
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user