From 6e30624eceb6f43c3759f6ea2a9b80935f3349f3 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 2 Oct 2026 15:54:44 +0200 Subject: [PATCH] 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%. --- modules/lagoon/attach/url_test.go | 167 +++++++++++++ modules/lagoon/validate_request_test.go | 301 ++++++++++++++++++++++++ modules/tide/multipart_test.go | 120 ++++++++++ modules/tide/normalize_upload_test.go | 95 ++++++++ 4 files changed, 683 insertions(+) create mode 100644 modules/tide/normalize_upload_test.go diff --git a/modules/lagoon/attach/url_test.go b/modules/lagoon/attach/url_test.go index b8b0ecf..95ac814 100644 --- a/modules/lagoon/attach/url_test.go +++ b/modules/lagoon/attach/url_test.go @@ -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) + } + } +} diff --git a/modules/lagoon/validate_request_test.go b/modules/lagoon/validate_request_test.go index cd9bf19..17d6e84 100644 --- a/modules/lagoon/validate_request_test.go +++ b/modules/lagoon/validate_request_test.go @@ -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(``) + 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") + } +} diff --git a/modules/tide/multipart_test.go b/modules/tide/multipart_test.go index 8845d99..0455436 100644 --- a/modules/tide/multipart_test.go +++ b/modules/tide/multipart_test.go @@ -294,3 +294,123 @@ func TestNormalizePublicationAlbumDates(t *testing.T) { t.Fatal("a Z album date must show as a diff") } } + +// TestMultipartPartEdges covers part validation, file reading and encoding +// edges: file attributes without a file, empty and value-only parts, +// default file names and content types, unicode and quoted names, a part +// that contains the boundary, a missing or tampered file, a file outside the +// fixture directory and the Content-Type handling of prepareRequest. +func TestMultipartPartEdges(t *testing.T) { + dir := t.TempDir() + png := []byte("\x89PNG\r\n\x1a\nfixture") + if err := os.MkdirAll(filepath.Join(dir, "files"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "files", "okładka.png"), png, 0o600); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(png) + pin := hex.EncodeToString(sum[:]) + + for name, req := range map[string]Request{ + "file attributes without a file": {Parts: []Part{{Name: "a", Filename: "x.png"}}}, + "content type without a file": {Parts: []Part{{Name: "a", ContentType: "image/png"}}}, + "sha without a file": {Parts: []Part{{Name: "a", SHA256: pin}}}, + "blank name": {Parts: []Part{{Name: " ", Value: "x"}}}, + "absolute file": {Parts: []Part{{Name: "a", File: "/etc/passwd", SHA256: pin}}}, + "short sha": {Parts: []Part{{Name: "a", File: "files/okładka.png", SHA256: "abc"}}}, + } { + if err := validateParts(req); err == nil { + t.Errorf("%s: validateParts accepted it", name) + } + } + if err := validateParts(Request{}); err != nil { + t.Fatalf("no parts: %v", err) + } + + parts := []Part{ + {Name: "empty", Value: ""}, + {Name: `tit"le`, Value: "Łódź"}, + {Name: "file", File: "files/okładka.png", SHA256: strings.ToUpper(pin)}, + } + body, ct, err := encodeParts(dir, parts) + if err != nil { + t.Fatal(err) + } + if ct != "multipart/form-data; boundary="+MultipartBoundary { + t.Fatalf("content type %q", ct) + } + text := string(body) + for _, want := range []string{ + `Content-Disposition: form-data; name="empty"`, + `Content-Disposition: form-data; name="tit\"le"` + "\r\n\r\nŁódź", + `Content-Disposition: form-data; name="file"; filename="okładka.png"`, + "Content-Type: application/octet-stream", + } { + if !strings.Contains(text, want) { + t.Errorf("encoded body lacks %q:\n%s", want, text) + } + } + again, _, err := encodeParts(dir, parts) + if err != nil || !bytes.Equal(body, again) { + t.Fatal("encoding is not deterministic") + } + if _, _, err := encodeParts(dir, []Part{{Name: "x", Value: "--" + MultipartBoundary}}); err == nil { + t.Error("a value holding the boundary was encoded") + } + for name, p := range map[string]Part{ + "missing": {Name: "f", File: "files/nope.png", SHA256: pin}, + "tampered": {Name: "f", File: "files/okładka.png", SHA256: strings.Repeat("0", 64)}, + "escaping": {Name: "f", File: "../okładka.png", SHA256: pin}, + } { + if _, err := readPartFile(dir, p); err == nil { + t.Errorf("%s part file was read", name) + } + } + outside := t.TempDir() + if err := os.WriteFile(filepath.Join(outside, "x.png"), png, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(outside, "x.png"), filepath.Join(dir, "files", "link.png")); err != nil { + t.Fatal(err) + } + if _, err := readPartFile(dir, Part{Name: "f", File: "files/link.png", SHA256: pin}); err == nil { + t.Error("a symlink leaving the fixture directory was read") + } + if raw, err := readPartFile("", Part{Name: "f", File: filepath.Join(dir, "files", "okładka.png")[len(dir)+1:], SHA256: pin}); err == nil && raw != nil { + t.Error("a part file resolved without its fixture directory") + } + + for name, tc := range map[string]struct { + headers map[string]string + want string + }{ + "none recorded": {nil, "multipart/form-data; boundary=" + MultipartBoundary}, + "stale boundary": {map[string]string{"content-type": "multipart/form-data; boundary=old"}, "multipart/form-data; boundary=" + MultipartBoundary}, + "unparsable": {map[string]string{"Content-Type": ";;;"}, "multipart/form-data; boundary=" + MultipartBoundary}, + "kept non-multipart": {map[string]string{"Content-Type": "text/plain"}, "text/plain"}, + } { + out, err := prepareRequest(Request{Method: "POST", Headers: tc.headers, Parts: parts[:1]}, dir) + if err != nil { + t.Fatal(err) + } + got := "" + for k, v := range out.Headers { + if strings.EqualFold(k, "Content-Type") { + got = v + } + } + if got != tc.want { + t.Errorf("%s: Content-Type %q, want %q", name, got, tc.want) + } + } + if out, err := prepareRequest(Request{Body: "a=1"}, dir); err != nil || out.Body != "a=1" { + t.Fatalf("no parts: %+v %v", out, err) + } + if _, err := prepareRequest(Request{Body: "a=1", Parts: parts[:1]}, dir); err == nil { + t.Fatal("body and parts were prepared") + } + if _, err := prepareRequest(Request{Parts: []Part{{Name: "f", File: "files/nope.png", SHA256: pin}}}, dir); err == nil { + t.Fatal("a missing part file was prepared") + } +} diff --git a/modules/tide/normalize_upload_test.go b/modules/tide/normalize_upload_test.go new file mode 100644 index 0000000..4d22962 --- /dev/null +++ b/modules/tide/normalize_upload_test.go @@ -0,0 +1,95 @@ +package tide + +import ( + "strings" + "testing" +) + +func normalizeOne(t *testing.T, body string, step Step, prefix string) (string, []Diff) { + t.Helper() + out, diffs := normalizeJSON([]byte(body), step, maskOptions{uploadPrefix: prefix}) + return strings.NewReplacer(`\u003c`, "<", `\u003e`, ">").Replace(string(out)), diffs +} + +// TestNormalizeMaskEdges covers the response normalizer's masks: upload +// URLs under the default and a custom prefix, look-alikes under another +// prefix, keys holding other types, ids and dates of the wrong shape, +// disabled paths and pass-through of bodies that are not JSON. +func TestNormalizeMaskEdges(t *testing.T) { + orig := DefaultUploadPrefix + "/6ab/f82/1c3/6abf821c3d4e5f6a7b8c9d.png" + thumb := DefaultUploadPrefix + "/6ab/f82/1c3/thumb_57_200_200_0_0_crop.png" + cases := []struct { + name, body, prefix string + step Step + want string + diffs int + }{ + {"original and thumb", `{"url":"` + orig + `","thumb_url":"` + thumb + `"}`, "", Step{}, + `{"thumb_url":"` + DefaultUploadPrefix + `//thumb__200_200_0_0_crop.png","url":"` + DefaultUploadPrefix + `//.png"}`, 0}, + {"custom prefix with a slash", `{"url":"/files/6ab/f82/1c3/6abf821c3d4e5f6a7b8c9d.jpg"}`, "/files/", Step{}, + `{"url":"/files//.jpg"}`, 0}, + {"upload under another prefix", `{"url":"/elsewhere/6ab/f82/1c3/6abf821c3d4e5f6a7b8c9d.png"}`, "", Step{}, "", 1}, + {"plain url kept", `{"url":"https://example.com/a.png"}`, "", Step{}, `{"url":"https://example.com/a.png"}`, 0}, + {"non-string url kept", `{"url":5,"thumb_url":null}`, "", Step{}, `{"thumb_url":null,"url":5}`, 0}, + {"slug never masked", `{"slug":"abc_id","id":7}`, "", Step{}, `{"id":"","slug":"abc_id"}`, 0}, + {"collection key", `{"collection_key":"e53f1bd22f01bbe8268079f016301ad5","client_id":null}`, "", Step{}, `{"client_id":null,"collection_key":""}`, 0}, + {"collection key of the wrong type", `{"collection_key":5}`, "", Step{}, "", 1}, + {"issued at", `{"token_issued_at":1700000000}`, "", Step{}, `{"token_issued_at":""}`, 0}, + {"wrong date shape", `{"created_at":"2020-01-01 10:00:00"}`, "", Step{}, "", 1}, + {"date of the wrong type", `{"updated_at":5}`, "", Step{}, "", 1}, + {"null date and id", `{"deleted_at":null,"genre_id":null}`, "", Step{}, `{"deleted_at":null,"genre_id":null}`, 0}, + {"decimal id", `{"id":1.5}`, "", Step{}, "", 1}, + {"string id", `{"album_id":"7"}`, "", Step{}, "", 1}, + {"id list", `{"collection_ids":[3,4]}`, "", Step{}, `{"collection_ids":["",""]}`, 0}, + {"checkpoint", `{"checkpoint":"2020-01-01T10:00:00+00:00"}`, "", Step{}, `{"checkpoint":""}`, 0}, + {"disabled by key", `{"created_at":"not a date"}`, "", Step{Normalize: []NormalizeRule{{Path: "created_at", Disable: true}}}, `{"created_at":"not a date"}`, 0}, + {"disabled by path", `{"errors":{"album_id":["The album id field is required."]}}`, "", Step{Normalize: []NormalizeRule{{Path: "$.errors.album_id[0]", Disable: true}}}, `{"errors":{"album_id":["The album id field is required."]}}`, 0}, + {"disabled path without $", `{"a":{"user_id":"x"}}`, "", Step{Normalize: []NormalizeRule{{Path: "a.user_id", Disable: true}, {Path: "a.user_id"}}}, `{"a":{"user_id":"x"}}`, 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, diffs := normalizeOne(t, tc.body, tc.step, tc.prefix) + if len(diffs) != tc.diffs { + t.Fatalf("diffs %+v, want %d", diffs, tc.diffs) + } + if tc.want != "" && got != tc.want { + t.Fatalf("got %s\nwant %s", got, tc.want) + } + }) + } + for _, raw := range []string{"", " ", "not json", ""} { + if got, diffs := normalizeOne(t, raw, Step{}, ""); got != raw || diffs != nil { + t.Errorf("non-JSON %q became %q %v", raw, got, diffs) + } + } + if lastPathKey("$.a.b[3]") != "b" || lastPathKey("$.x") != "x" || !strings.HasPrefix(maskOptions{}.prefix(), "/storage") { + t.Error("path key helpers") + } +} + +// TestCoverageReportHelpers covers the coverage table and batch helpers the +// parity CLI prints. +func TestCoverageReportHelpers(t *testing.T) { + c := Coverage{Rows: []CoverageRow{{ID: "GET_x", Status: "ported", Outcome: "pass"}}} + if h := CoverageHeaders(); strings.Join(h, ",") != "route,status,outcome" { + t.Fatalf("headers %v", h) + } + if rows := c.CoverageRows(); len(rows) != 1 || strings.Join(rows[0], ",") != "GET_x,ported,pass" { + t.Fatalf("rows %v", rows) + } + if c.ResumeLine() != "manifest recording complete" { + t.Fatal(c.ResumeLine()) + } + c.ResumeRemaining = 3 + if c.ResumeLine() != "resume remaining 3" { + t.Fatal(c.ResumeLine()) + } + for in, want := range map[string]int{"": 0, " ": 0, "7": 7} { + if n, err := ParseNextBatch(in); err != nil || n != want { + t.Errorf("ParseNextBatch(%q) = %d %v", in, n, err) + } + } + if _, err := ParseNextBatch("x"); err == nil { + t.Error("a non-number batch") + } +}