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:
Jakub Zych
2026-10-02 15:54:44 +02:00
parent d1650e8213
commit 6e30624ece
4 changed files with 683 additions and 0 deletions

View File

@@ -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)
}
}
}

View File

@@ -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")
}
}