test(12-05): bring the Phase 12 framework packages to full unit coverage
- 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%.
This commit is contained in:
@@ -6,12 +6,16 @@ import (
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"image"
|
||||
"image/gif"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"gocloud.dev/blob"
|
||||
"gocloud.dev/blob/memblob"
|
||||
@@ -195,3 +199,166 @@ func TestThumbBrokenSourceServesPlaceholder(t *testing.T) {
|
||||
t.Fatal("an out-of-range size must stay an error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestThumbModesAndFormats generates crop, exact, auto and fit thumbnails
|
||||
// from PNG, GIF and JPEG originals and checks the stored bytes decode to
|
||||
// the requested box in the original's format.
|
||||
func TestThumbModesAndFormats(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
bucket := memblob.OpenBucket(nil)
|
||||
t.Cleanup(func() { _ = bucket.Close() })
|
||||
src := image.NewRGBA(image.Rect(0, 0, 120, 60))
|
||||
encoders := map[string]func(*bytes.Buffer) error{
|
||||
"png": func(b *bytes.Buffer) error { return png.Encode(b, src) },
|
||||
"gif": func(b *bytes.Buffer) error { return gif.Encode(b, src, nil) },
|
||||
"jpg": func(b *bytes.Buffer) error { return jpeg.Encode(b, src, nil) },
|
||||
}
|
||||
id := uint(500)
|
||||
for ext, enc := range encoders {
|
||||
var buf bytes.Buffer
|
||||
if err := enc(&buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, mode := range []string{"crop", "exact", "auto", "fit", "CROP"} {
|
||||
id++
|
||||
f := &File{ID: id, DiskName: fmt.Sprintf("m%02dmodes%03d.%s", id%100, id, ext)}
|
||||
if err := bucket.WriteAll(ctx, BlobKey(f.DiskName), buf.Bytes(), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
url, err := f.Thumb(ctx, bucket, 40, 40, mode)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", ext, mode, err)
|
||||
}
|
||||
key := strings.TrimPrefix(url, PublicPathPrefix()+"/")
|
||||
raw, err := bucket.ReadAll(ctx, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, format, err := image.DecodeConfig(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantFormat := map[string]string{"png": "png", "gif": "gif", "jpg": "jpeg"}[ext]
|
||||
if format != wantFormat {
|
||||
t.Errorf("%s %s: thumbnail format %s", ext, mode, format)
|
||||
}
|
||||
switch strings.ToLower(mode) {
|
||||
case "crop", "exact":
|
||||
if cfg.Width != 40 || cfg.Height != 40 {
|
||||
t.Errorf("%s %s: %dx%d, want 40x40", ext, mode, cfg.Width, cfg.Height)
|
||||
}
|
||||
default:
|
||||
if cfg.Width != 40 || cfg.Height != 20 {
|
||||
t.Errorf("%s %s: %dx%d, want 40x20 (fit)", ext, mode, cfg.Width, cfg.Height)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
f := &File{ID: 1, DiskName: "noextension"}
|
||||
if got := fileExt(f.DiskName); got != "jpg" {
|
||||
t.Fatalf("fileExt without extension = %q", got)
|
||||
}
|
||||
if got := fileExt("A.PNG"); got != "png" {
|
||||
t.Fatalf("fileExt upper case = %q", got)
|
||||
}
|
||||
if _, err := (*File)(nil).Thumb(ctx, bucket, 10, 10, "crop"); err == nil {
|
||||
t.Fatal("nil file")
|
||||
}
|
||||
if _, err := f.Thumb(ctx, nil, 10, 10, "crop"); err == nil {
|
||||
t.Fatal("nil bucket")
|
||||
}
|
||||
if _, err := (&File{ID: 2, DiskName: "abcdefghi.p-ng"}).Thumb(ctx, bucket, 10, 10, "crop"); err == nil {
|
||||
t.Fatal("unsafe extension")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBucketAndURLEdges covers bucket URL normalisation, OpenBucket and
|
||||
// Publish refusals, partition and URL helpers and static prefix stripping.
|
||||
func TestBucketAndURLEdges(t *testing.T) {
|
||||
for raw, want := range map[string]string{
|
||||
"mem://": "mem://",
|
||||
"memory://anything": "mem://",
|
||||
"fileblob:///tmp/x": "file:///tmp/x?create_dir=true",
|
||||
"file:///tmp/y": "file:///tmp/y?create_dir=true",
|
||||
"file:///tmp/z?create_dir=false": "file:///tmp/z?create_dir=false",
|
||||
"s3://bucket?region=eu-central-1": "s3://bucket?region=eu-central-1",
|
||||
} {
|
||||
got, err := normalizeBucketURL(raw)
|
||||
if err != nil || got != want {
|
||||
t.Errorf("normalizeBucketURL(%q) = %q %v, want %q", raw, got, err, want)
|
||||
}
|
||||
}
|
||||
if _, err := normalizeBucketURL("%zz"); err == nil {
|
||||
t.Error("an unparsable bucket URL must be an error")
|
||||
}
|
||||
if _, err := OpenBucket(t.Context(), nil); err == nil {
|
||||
t.Error("nil config")
|
||||
}
|
||||
open := func(body string) error {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "storage.yaml"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{Dir: dir, Env: "development", Environ: []string{"SUMMER_ENV=development"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := OpenBucket(t.Context(), cfg)
|
||||
if err == nil {
|
||||
_ = b.Close()
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := open("uploads:\n bucket_url: \"\"\n"); err == nil {
|
||||
t.Error("an empty bucket_url must fail")
|
||||
}
|
||||
if err := open("uploads:\n bucket_url: \"nope://x\"\n"); err == nil {
|
||||
t.Error("an unknown scheme must fail")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
if err := open("uploads:\n bucket_url: \"file://" + dir + "/up\"\n"); err != nil {
|
||||
t.Fatalf("file bucket: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { setPublicPathPrefix(defaultPublicPathPrefix) })
|
||||
if got := PublicPathPrefix(); got != defaultPublicPathPrefix {
|
||||
t.Fatalf("default prefix = %q", got)
|
||||
}
|
||||
if err := Publish(nil, memblob.OpenBucket(nil)); err == nil {
|
||||
t.Error("Publish with a nil app")
|
||||
}
|
||||
if err := Publish(backpack.New(nil), nil); err == nil {
|
||||
t.Error("Publish with a nil bucket")
|
||||
}
|
||||
if got := PartitionDirectory("ab"); got != "ab/" {
|
||||
t.Errorf("short partition = %q", got)
|
||||
}
|
||||
if got := PartitionDirectory("abcdefghijkl"); got != "abc/def/ghi/" {
|
||||
t.Errorf("partition = %q", got)
|
||||
}
|
||||
setPublicPathPrefix("")
|
||||
if got := PublicURL("/a/b.png"); got != "/a/b.png" {
|
||||
t.Errorf("PublicURL without prefix = %q", got)
|
||||
}
|
||||
setPublicPathPrefix("/files/")
|
||||
if got := PublicURL("a/b.png"); got != "/files/a/b.png" {
|
||||
t.Errorf("PublicURL with a trailing slash prefix = %q", got)
|
||||
}
|
||||
setPublicPathPrefix(defaultPublicPathPrefix)
|
||||
if (*File)(nil).URL() != "" {
|
||||
t.Error("nil File URL")
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
path, prefix, key string
|
||||
ok bool
|
||||
}{
|
||||
{"/abc/def.png", "", "abc/def.png", true},
|
||||
{"/storage", "/storage", "", false},
|
||||
{"/storage/a.png", "/storage", "a.png", true},
|
||||
{"/storagex/a.png", "/storage", "", false},
|
||||
} {
|
||||
key, ok := stripStaticPrefix(tc.path, tc.prefix)
|
||||
if key != tc.key || ok != tc.ok {
|
||||
t.Errorf("stripStaticPrefix(%q, %q) = %q %v", tc.path, tc.prefix, key, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ package lagoon
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"math"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
@@ -363,3 +366,301 @@ func TestValidateRequestErrorKeysDeclarationOrder(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user