- lagoon: Go-typed request values (typed slices and maps, sized integers, floats, file pointers, typed path lookups), regex delimiters, mimes sniffing (jpg/jpeg, SVG, PHP names, unreadable content), UploadedFileFromHeader, the rule builders, custom catalog lines with :input/:index/:position, and exists: against Postgres (text compare, inferred and NULL columns, arrays, unsafe identifiers, missing tables) - lagoon/attach: thumbnails in every mode from PNG, GIF and JPEG originals, bucket URL normalisation, OpenBucket/Publish refusals, URL helpers and static prefix stripping - tide: part validation and encoding edges, symlinks out of the fixture directory, Content-Type handling, every response mask and the coverage report helpers Coverage: lagoon 84.4%, lagoon/attach 87.7%, tide 81.4%, beachcomber 84.3%, beachcomber/typesense 95.9%.
667 lines
30 KiB
Go
667 lines
30 KiB
Go
package lagoon
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"math"
|
|
"mime/multipart"
|
|
"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)
|
|
}
|
|
}
|
|
|
|
func fileOf(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
|
|
}}
|
|
}
|
|
|
|
// TestValidateRequestGoTypedValues covers values a handler builds in Go
|
|
// rather than decodes from JSON: typed slices and maps, sized integers,
|
|
// floats (cast to strings as PHP 8 does), file pointers and path lookups
|
|
// on typed slices.
|
|
func TestValidateRequestGoTypedValues(t *testing.T) {
|
|
png := []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR")
|
|
rr := func(field, spec string) RequestRule { return RequestRule{Field: field, Rules: ParseRules(spec)} }
|
|
cases := []struct {
|
|
name string
|
|
input map[string]any
|
|
rules []RequestRule
|
|
want map[string][]string
|
|
}{
|
|
{"string slice min", map[string]any{"a": []string{"x"}}, []RequestRule{rr("a", "array|min:2")},
|
|
map[string][]string{"a": {"The a must have at least 2 items."}}},
|
|
{"string slice wildcard", map[string]any{"a": []string{"x", ""}}, []RequestRule{rr("a.*", "required")},
|
|
map[string][]string{"a.1": {"The a.1 field is required."}}},
|
|
{"map slice wildcard", map[string]any{"a": []map[string]any{{"c": "v"}, {"c": ""}}}, []RequestRule{rr("a.*.c", "required")},
|
|
map[string][]string{"a.1.c": {"The a.1.c field is required."}}},
|
|
{"typed slice of ints", map[string]any{"a": []int{1, 2, 3}}, []RequestRule{rr("a", "array|max:2")},
|
|
map[string][]string{"a": {"The a may not have more than 2 items."}}},
|
|
{"sized integers", map[string]any{"i8": int8(5), "u64": uint64(7), "f32": float32(2.5), "u": uint(3)},
|
|
[]RequestRule{rr("i8", "integer|max:4"), rr("u64", "integer|min:8"), rr("f32", "numeric|min:3"), rr("u", "integer|size:3")},
|
|
map[string][]string{"i8": {"The i8 may not be greater than 4."}, "u64": {"The u64 must be at least 8."}, "f32": {"The f32 must be at least 3."}}},
|
|
{"accepted typed", map[string]any{"a": int64(1), "b": json.Number("1"), "c": float64(1), "d": int(1), "e": json.Number("2")},
|
|
[]RequestRule{rr("a", "accepted"), rr("b", "accepted"), rr("c", "accepted"), rr("d", "accepted"), rr("e", "accepted")},
|
|
map[string][]string{"e": {"The e must be accepted."}}},
|
|
{"boolean typed", map[string]any{"a": int64(0), "b": json.Number("1"), "c": float64(2), "d": int(1)},
|
|
[]RequestRule{rr("a", "boolean"), rr("b", "boolean"), rr("c", "boolean"), rr("d", "boolean")},
|
|
map[string][]string{"c": {"The c field must be true or false."}}},
|
|
{"integer limits", map[string]any{"big": uint64(math.MaxUint64), "ok": uint(5), "f": float32(5), "n": json.Number("5.0"), "e": json.Number("1e3")},
|
|
[]RequestRule{rr("big", "integer"), rr("ok", "integer"), rr("f", "integer"), rr("n", "integer"), rr("e", "integer")},
|
|
map[string][]string{"big": {"The big must be an integer."}}},
|
|
{"float string length", map[string]any{"f": float64(1e20)}, []RequestRule{rr("f", "max:3")},
|
|
map[string][]string{"f": {"The f may not be greater than 3 characters."}}},
|
|
{"numeric json float", map[string]any{"f": json.Number("0.5"), "g": json.Number("1.5e1")}, []RequestRule{rr("f", "numeric|between:1,2"), rr("g", "numeric|size:15")},
|
|
map[string][]string{"f": {"The f must be between 1 and 2."}}},
|
|
{"file pointer", map[string]any{"p": &UploadedFile{Filename: "a.png", Size: 2048, Open: fileOf("a.png", 2048, png).Open}}, []RequestRule{rr("p", "file|max:1")},
|
|
map[string][]string{"p": {"The p may not be greater than 1 kilobytes."}}},
|
|
{"nil file pointer", map[string]any{"p": (*UploadedFile)(nil)}, []RequestRule{rr("p", "file")},
|
|
map[string][]string{"p": {"The p must be a file."}}},
|
|
{"empty upload is not required", map[string]any{"p": UploadedFile{}}, []RequestRule{rr("p", "required")},
|
|
map[string][]string{"p": {"The p field is required."}}},
|
|
{"typed path lookups", map[string]any{"s": []string{"x", "y"}, "m": []map[string]any{{"c": "v"}}, "l": []any{"z"}},
|
|
[]RequestRule{rr("s.1", "in:y"), rr("s.5", "required"), rr("m.0.c", "in:v"), rr("m.3.c", "required"), rr("l.01", "required"), rr("l.0", "in:z")},
|
|
map[string][]string{"s.5": {"The s.5 field is required."}, "m.3.c": {"The m.3.c field is required."}, "l.01": {"The l.01 field is required."}}},
|
|
{"regex delimiters", map[string]any{"a": "ab", "b": "ab", "c": "ab", "d": "ab", "e": "a|b"},
|
|
[]RequestRule{rr("a", "regex:{^a}"), rr("b", "regex:(^a)"), rr("c", "regex:[^b]"), rr("d", "regex:<^b>"), rr("e", `regex:/^a\|b$/|max:3`)},
|
|
map[string][]string{"c": {"The c format is invalid."}, "d": {"The d format is invalid."}}},
|
|
{"not regex on a number", map[string]any{"n": json.Number("12"), "m": true}, []RequestRule{rr("n", "not_regex:/^1/"), rr("m", "not_regex:/x/")},
|
|
map[string][]string{"n": {"The n format is invalid."}, "m": {"The m format is invalid."}}},
|
|
{"in with a typed array", map[string]any{"a": []string{"x", "y"}, "b": []string{"x"}}, []RequestRule{rr("a", "array|in:x,y"), rr("b", "in:x")},
|
|
map[string][]string{"b": {"The selected b is invalid."}}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := mustValidate(t, inLocale("en"), tc.input, tc.rules)
|
|
if len(got) == 0 && len(tc.want) == 0 {
|
|
return
|
|
}
|
|
if !reflect.DeepEqual(got, tc.want) {
|
|
t.Fatalf("got %#v\nwant %#v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestValidateRequestMimesSniffing: mimes sniffs the content, treats jpg
|
|
// and jpeg as one, detects SVG text, refuses a PHP file name unless php is
|
|
// listed, and fails closed for content it cannot read or recognise.
|
|
func TestValidateRequestMimesSniffing(t *testing.T) {
|
|
jpegBytes := []byte("\xff\xd8\xff\xe0\x00\x10JFIF\x00")
|
|
png := []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR")
|
|
svg := []byte(`<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"></svg>`)
|
|
broken := UploadedFile{Filename: "a.png", Size: 10, Open: func() (io.ReadCloser, error) { return nil, io.ErrUnexpectedEOF }}
|
|
cases := []struct {
|
|
name string
|
|
file UploadedFile
|
|
spec string
|
|
pass bool
|
|
}{
|
|
{"jpeg as jpg", fileOf("a.jpeg", 10, jpegBytes), "mimes:jpeg", true},
|
|
{"jpg alias", fileOf("a.jpg", 10, jpegBytes), "mimes:jpg", true},
|
|
{"svg text", fileOf("a.svg", 10, svg), "mimes:svg", true},
|
|
{"svg is an image", fileOf("a.svg", 10, svg), "image", true},
|
|
{"php name refused", fileOf("x.php", 10, png), "mimes:png", false},
|
|
{"phtml name refused", fileOf("x.PHTML", 10, png), "mimes:png", false},
|
|
{"php allowed when listed", fileOf("x.php", 10, png), "mimes:png,php", true},
|
|
{"unknown binary", fileOf("a.bin", 10, []byte{0x00, 0x01, 0x02, 0xfe}), "mimes:png", false},
|
|
{"octet stream as bin", fileOf("a.bin", 10, []byte{0x00, 0x01, 0x02, 0xfe}), "mimes:bin", true},
|
|
{"empty content", fileOf("a.png", 0, nil), "mimes:png", false},
|
|
{"open error", broken, "mimes:png", false},
|
|
{"no opener", UploadedFile{Filename: "a.png", Size: 3}, "mimes:png", false},
|
|
{"text is not an image", fileOf("a.png", 5, []byte("hello")), "image", false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := mustValidate(t, inLocale("en"), map[string]any{"f": tc.file}, []RequestRule{{Field: "f", Rules: ParseRules(tc.spec)}})
|
|
if (len(got) == 0) != tc.pass {
|
|
t.Fatalf("%s on %s: %v, want pass=%v", tc.spec, tc.file.Filename, got, tc.pass)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestValidateRequestUploadedFileFromHeader adapts a parsed multipart part.
|
|
func TestValidateRequestUploadedFileFromHeader(t *testing.T) {
|
|
var body bytes.Buffer
|
|
mw := multipart.NewWriter(&body)
|
|
part, err := mw.CreateFormFile("photo", "cover.png")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, _ = part.Write([]byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"))
|
|
if err := mw.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
form, err := multipart.NewReader(&body, mw.Boundary()).ReadForm(1 << 20)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = form.RemoveAll() })
|
|
f := UploadedFileFromHeader(form.File["photo"][0])
|
|
if f.Filename != "cover.png" || f.Size != 16 || f.Header.Get("Content-Type") == "" {
|
|
t.Fatalf("adapted file %+v", f)
|
|
}
|
|
rules := []RequestRule{{Field: "photo", Rules: ParseRules("required|file|image|mimes:png|max:1")}}
|
|
if got := mustValidate(t, inLocale("en"), map[string]any{"photo": f}, rules); got != nil {
|
|
t.Fatalf("parsed part: %v", got)
|
|
}
|
|
empty := UploadedFileFromHeader(nil)
|
|
if empty.Open != nil || empty.Filename != "" {
|
|
t.Fatalf("nil header: %+v", empty)
|
|
}
|
|
if got := mustValidate(t, inLocale("en"), map[string]any{"photo": empty}, rules); len(got["photo"]) != 1 {
|
|
t.Fatalf("empty part: %v", got)
|
|
}
|
|
}
|
|
|
|
// TestValidateRequestRuleBuilders covers Rule.Name, Rule.Args, In and
|
|
// CustomRule's nil guard.
|
|
func TestValidateRequestRuleBuilders(t *testing.T) {
|
|
in := In(`EP 7"`, "LP,2")
|
|
if in.Name() != "in" || !reflect.DeepEqual(in.Args(), []string{`EP 7"`, "LP,2"}) {
|
|
t.Fatalf("In = %q %v", in.Name(), in.Args())
|
|
}
|
|
args := in.Args()
|
|
args[0] = "changed"
|
|
if in.Args()[0] != `EP 7"` {
|
|
t.Fatal("Args returned the rule's own slice")
|
|
}
|
|
custom := CustomRule(func(string, any) (string, bool) { return "", false })
|
|
if custom.Name() != "custom" || len(custom.Args()) != 0 {
|
|
t.Fatalf("custom rule %q %v", custom.Name(), custom.Args())
|
|
}
|
|
if r := ParseRules("max:3")[0]; r.Name() != "max" || !reflect.DeepEqual(r.Args(), []string{"3"}) {
|
|
t.Fatalf("parsed rule %q %v", r.Name(), r.Args())
|
|
}
|
|
rules := []RequestRule{{Field: "f", Rules: []Rule{In(`EP 7"`, "LP,2")}}}
|
|
if got := mustValidate(t, inLocale("en"), map[string]any{"f": "LP,2"}, rules); got != nil {
|
|
t.Fatalf("In with a comma: %v", got)
|
|
}
|
|
defer func() {
|
|
if recover() == nil {
|
|
t.Fatal("CustomRule(nil) did not panic")
|
|
}
|
|
}()
|
|
CustomRule(nil)
|
|
}
|
|
|
|
// TestValidateRequestCustomLinePlaceholders: a custom catalog line for an
|
|
// expanded attribute gets :attribute, :input, :index, :position and the
|
|
// rule's own placeholders replaced, as Laravel's makeReplacements does.
|
|
func TestValidateRequestCustomLinePlaceholders(t *testing.T) {
|
|
raw, err := os.ReadFile(filepath.Join("..", "phrasebook", "lang", "en", "validation.yaml"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
custom := "custom:\n items:\n \"1\":\n name:\n max: \"Item :position (index :index) :attribute is too long: ':input' is over :max.\"\n"
|
|
var kept []string
|
|
skipping := false
|
|
for _, line := range strings.Split(string(raw), "\n") {
|
|
if strings.HasPrefix(line, "custom:") {
|
|
skipping = true
|
|
continue
|
|
}
|
|
if skipping && (strings.HasPrefix(line, " ") || line == "") {
|
|
continue
|
|
}
|
|
skipping = false
|
|
kept = append(kept, line)
|
|
}
|
|
files := fstest.MapFS{"lang/en/validation.yaml": &fstest.MapFile{Data: []byte(strings.Join(kept, "\n") + "\n" + custom)}}
|
|
cat := phrasebook.NewCatalog()
|
|
if err := cat.Load("lagoon", files); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tr := phrasebook.NewTranslator(cat, phrasebook.Options{Locale: "en", Fallback: "en"})
|
|
input := map[string]any{"items": []any{map[string]any{"name": "abc"}, map[string]any{"name": "toolong"}}}
|
|
rules := []RequestRule{{Field: "items.*.name", Rules: ParseRules("string|max:2")}}
|
|
got, err := ValidateRequest(inLocale("en"), nil, input, rules, tr)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := map[string][]string{
|
|
"items.0.name": {"The items.0.name may not be greater than 2 characters."},
|
|
"items.1.name": {"Item 2 (index 1) items.1.name is too long: 'toolong' is over 2."},
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("got %#v", got)
|
|
}
|
|
// :input of a value PHP cannot cast stays as written.
|
|
if s := replaceInput("got :input", []any{1}); s != "got :input" {
|
|
t.Fatalf("replaceInput on an array = %q", s)
|
|
}
|
|
if s := replaceIndexes("row :position", "items.name"); s != "row :position" {
|
|
t.Fatalf("replaceIndexes without an index = %q", s)
|
|
}
|
|
}
|
|
|
|
// TestValidateRequestExistsRule runs exists: against Postgres: the value
|
|
// compared as text, the column defaulting to the attribute (or its last
|
|
// wildcard segment), arrays of distinct values, injection-shaped values as
|
|
// plain misses, and unsafe identifiers or a missing table as errors.
|
|
func TestValidateRequestExistsRule(t *testing.T) {
|
|
ctx := inLocale("en")
|
|
gdb, err := Use(ctx, lagoonDB(t))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, stmt := range []string{
|
|
`DROP TABLE IF EXISTS lagoon_exists_items`,
|
|
`CREATE TABLE lagoon_exists_items (id INTEGER PRIMARY KEY, code TEXT)`,
|
|
`INSERT INTO lagoon_exists_items (id, code) VALUES (1, 'a'), (2, 'b'), (3, NULL)`,
|
|
} {
|
|
if err := gdb.Exec(stmt).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
t.Cleanup(func() { _ = gdb.Exec(`DROP TABLE IF EXISTS lagoon_exists_items`).Error })
|
|
run := func(input map[string]any, rules ...RequestRule) map[string][]string {
|
|
t.Helper()
|
|
got, err := ValidateRequest(ctx, gdb, input, rules, requestTranslator(t))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return got
|
|
}
|
|
rr := func(field, spec string) RequestRule { return RequestRule{Field: field, Rules: ParseRules(spec)} }
|
|
invalid := func(attr string) []string { return []string{"The selected " + attr + " is invalid."} }
|
|
for _, tc := range []struct {
|
|
name string
|
|
input map[string]any
|
|
rule RequestRule
|
|
want map[string][]string
|
|
}{
|
|
{"hit", map[string]any{"id": json.Number("1")}, rr("id", "exists:lagoon_exists_items,id"), nil},
|
|
{"miss", map[string]any{"id": json.Number("9")}, rr("id", "exists:lagoon_exists_items,id"), map[string][]string{"id": invalid("id")}},
|
|
{"injection shaped", map[string]any{"id": "1 OR 1=1"}, rr("id", "exists:lagoon_exists_items,id"), map[string][]string{"id": invalid("id")}},
|
|
{"true is 1", map[string]any{"id": true}, rr("id", "exists:lagoon_exists_items,id"), nil},
|
|
{"column from attribute", map[string]any{"code": "a"}, rr("code", "exists:lagoon_exists_items"), nil},
|
|
{"NULL column", map[string]any{"code": "b"}, rr("code", "exists:lagoon_exists_items,NULL"), nil},
|
|
{"schema prefix", map[string]any{"code": "b"}, rr("code", "exists:public.lagoon_exists_items,code"), nil},
|
|
{"array hit with duplicates", map[string]any{"ids": []any{json.Number("1"), json.Number("2"), json.Number("2")}}, rr("ids", "array|exists:lagoon_exists_items,id"), nil},
|
|
{"array miss", map[string]any{"ids": []any{json.Number("1"), json.Number("9")}}, rr("ids", "array|exists:lagoon_exists_items,id"), map[string][]string{"ids": invalid("ids")}},
|
|
{"empty array", map[string]any{"ids": []any{}}, rr("ids", "array|exists:lagoon_exists_items,id"), nil},
|
|
{"nested array", map[string]any{"ids": []any{[]any{"1"}}}, rr("ids", "array|exists:lagoon_exists_items,id"), map[string][]string{"ids": invalid("ids")}},
|
|
{"wildcard column", map[string]any{"items": []any{map[string]any{"code": "a"}, map[string]any{"code": "z"}}}, rr("items.*.code", "exists:lagoon_exists_items"), map[string][]string{"items.1.code": invalid("items.1.code")}},
|
|
// Laravel counts an object's values like a list's.
|
|
{"object values", map[string]any{"id": map[string]any{"x": json.Number("1")}}, rr("id", "exists:lagoon_exists_items,id"), nil},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := run(tc.input, tc.rule)
|
|
if len(got) == 0 && len(tc.want) == 0 {
|
|
return
|
|
}
|
|
if !reflect.DeepEqual(got, tc.want) {
|
|
t.Fatalf("got %#v, want %#v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
if _, err := ValidateRequest(ctx, gdb, map[string]any{"bad-col": "a"}, []RequestRule{rr("bad-col", "exists:lagoon_exists_items")}, nil); err == nil {
|
|
t.Error("an unsafe column taken from the attribute must be an error")
|
|
}
|
|
if _, err := ValidateRequest(ctx, gdb, map[string]any{"id": "1"}, []RequestRule{rr("id", "exists:lagoon_no_such_table,id")}, nil); err == nil {
|
|
t.Error("a missing table must be an error")
|
|
}
|
|
if _, err := ValidateRequest(ctx, gdb, map[string]any{"ids": []any{"1"}}, []RequestRule{rr("ids", "array|exists:lagoon_no_such_table,id")}, nil); err == nil {
|
|
t.Error("a missing table must be an error for arrays too")
|
|
}
|
|
}
|