package cabana_test import ( "bytes" "context" "encoding/json" "fmt" "net/http" "slices" "strings" "testing" "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/lagoon/attach" ) // TestFileuploadCompile: every fileupload compile error stops boot naming // the field's file (D-08, D-09). func TestFileuploadCompile(t *testing.T) { photo := func(body string) string { return " photos:\n type: fileupload\n" + body } for name, tc := range map[string]struct { field string want string }{ "unknown key": {photo(" mode: image\n resize: true\n"), "unknown field resize"}, "datepicker key": {photo(" firstDay: 1\n"), "firstDay is only valid on type: datepicker"}, "unknown mode": {photo(" mode: video\n"), `mode "video" must be image or file on type: fileupload`}, "image-mode fileType": {photo(" mode: image\n fileTypes: [png, pdf]\n"), "fileTypes: pdf is not an image type"}, "bad fileType": {photo(" fileTypes: [p-f]\n"), `fileTypes: "p-f" is not a file extension`}, "bad mimeType": {photo(" mimeTypes: ['no such']\n"), `mimeTypes: "no such" is not a MIME type or extension`}, "maxFilesize zero": {photo(" maxFilesize: 0\n"), "must be a positive number of megabytes"}, "maxFilesize text": {photo(" maxFilesize: big\n"), "must be a positive number of megabytes"}, "maxFiles zero": {photo(" maxFiles: 0\n"), "maxFiles \"0\" must be a positive integer"}, "imageWidth zero": {photo(" imageWidth: 0\n"), "imageWidth"}, "imageHeight huge": {photo(" imageHeight: 99999\n"), "imageHeight"}, "thumbOptions key": {photo(" thumbOptions:\n size: 1\n"), "thumbOptions: unknown field size (only mode is supported)"}, "thumbOptions mode": {photo(" thumbOptions:\n mode: stretch\n"), `thumbOptions: mode "stretch" must be auto, exact, crop or fit`}, "thumbOptions list": {photo(" thumbOptions: [crop]\n"), "thumbOptions: must be a mapping"}, "useCaption not bool": {photo(" useCaption: maybe\n"), "useCaption:"}, "no relation": {" brochure:\n type: fileupload\n", "field brochure: is not an attachment relation the model declares in AttachRelations"}, "maxFiles on attachOne": {" manual:\n type: fileupload\n maxFiles: 2\n", "field manual: maxFiles is only valid on an attachMany relation"}, "key on another type": {" name2:\n type: text\n maxFiles: 2\n", "maxFiles is only valid on type: fileupload"}, } { t.Run(name, func(t *testing.T) { err := bootGadgetFields(t, dfGadgetFields(tc.field)) if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), "models/gadget/fields.yaml") { t.Fatalf("err = %v, want %q naming the file", err, tc.want) } }) } // A fileupload on a model without attach.Owner (the members manage // form) stops boot. member := "fields:\n email:\n type: text\n avatar:\n type: fileupload\n" err := activateDeferred(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/member/fields.yaml": member})}, nil) if err == nil || !strings.Contains(err.Error(), "type fileupload needs a model implementing attach.Owner") { t.Fatalf("model without attach.Owner: %v", err) } // maxFilesize above http.body_limits.upload_bytes stops boot. err = bootGadgetFields(t, dfGadgetFields(photo(" maxFilesize: 2\n"))) if err != nil { t.Fatalf("2 MB without an upload cap: %v", err) } err = activateDeferred(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/gadget/fields.yaml": dfGadgetFields(photo(" maxFilesize: 2\n"))})}, map[string]any{"http.body_limits.upload_bytes": 1048576}) if err == nil || !strings.Contains(err.Error(), "field photos: maxFilesize plus multipart overhead exceeds http.body_limits.upload_bytes") { t.Fatalf("maxFilesize over upload_bytes: %v", err) } // Equality leaves no room for multipart framing. err = activateDeferred(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/gadget/fields.yaml": dfGadgetFields(photo(" maxFilesize: 1\n"))})}, map[string]any{"http.body_limits.upload_bytes": 1048576}) if err == nil || !strings.Contains(err.Error(), "field photos: maxFilesize plus multipart overhead exceeds http.body_limits.upload_bytes") { t.Fatalf("maxFilesize equal to upload_bytes: %v", err) } if err := activateDeferred(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/gadget/fields.yaml": dfGadgetFields(photo(" maxFilesize: 1\n"))})}, map[string]any{"http.body_limits.upload_bytes": 1048576 + 64<<10}); err != nil { t.Fatalf("maxFilesize with 64 KiB headroom: %v", err) } if err := bootGadgetFields(t, dfGadgetFields(photo(" mode: image\n fileTypes: jpg|png\n mimeTypes: image/png, png\n maxFiles: 2\n imageWidth: 120\n thumbOptions:\n mode: fit\n useCaption: true\n prompt: Drop\n"))); err != nil { t.Fatalf("every key: %v", err) } } // photosPath and manualPath build a gadget's photos file route. func photosPath(gadget uint, rest string) string { return dfPath(gadget, "/files/photos"+rest) } // fileItems decodes a file list. func fileItems(t *testing.T, c dfClient, gadget uint, field, key string) []cabana.FileItem { t.Helper() var h map[string]string if key != "" { h = sk(key) } return fileList(t, c.do(t, http.MethodGet, dfPath(gadget, "/files/"+field), nil, h)) } func itemIDs(items []cabana.FileItem) []uint { out := make([]uint, 0, len(items)) for _, it := range items { out = append(out, it.ID) } return out } func (e *dfEnv) fileRow(t *testing.T, id uint) (attach.File, bool) { t.Helper() var f attach.File err := e.db.Where("id = ?", id).Limit(1).Find(&f).Error if err != nil { t.Fatal(err) } return f, f.ID != 0 } func (e *dfEnv) blob(t *testing.T, f attach.File) bool { t.Helper() ok, err := e.bucket.Exists(context.Background(), attach.BlobKey(f.DiskName)) if err != nil { t.Fatal(err) } return ok } // TestFileuploadUpload: an upload on id 0 is 201 and pending; the request // cap answers 413 and the field limits answer 422 on the field in the // request locale; maxFiles counts the visible files at upload; the list // orders by sort_order and flags pending uploads (D-03, D-08, D-09). func TestFileuploadUpload(t *testing.T) { env := newDeferredEnv(t) key := newSessionKey(t) png := conformPNG(t) up := env.a.upload(t, photosPath(0, ""), "a.png", png, sk(key)) want(t, "upload", up, http.StatusCreated) var item cabana.Envelope[cabana.FileItem] if err := json.Unmarshal(up.Body.Bytes(), &item); err != nil || !item.Data.Pending || item.Data.FileName != "a.png" || item.Data.URL == "" || item.Data.ContentType != "image/png" { t.Fatalf("upload item = %s (%v)", up.Body.String(), err) } before := env.state(t) // photos: maxFilesize 0.5 MB, cap 512 KiB + 64 KiB of multipart framing. big := append(append([]byte{}, png...), bytes.Repeat([]byte{0}, 700<<10)...) want(t, "body past the cap", env.a.upload(t, photosPath(0, ""), "big.png", big, sk(key)), http.StatusRequestEntityTooLarge) over := append(append([]byte{}, png...), bytes.Repeat([]byte{0}, 530<<10)...) rec := env.a.upload(t, photosPath(0, ""), "over.png", over, sk(key)) want(t, "file over maxFilesize", rec, http.StatusUnprocessableEntity) if d := errorDetails(t, rec)["photos"]; len(d) != 1 || d[0] != "The photos may not be greater than 512 kilobytes." { t.Fatalf("size message %v", d) } rec = env.a.upload(t, photosPath(0, ""), "x.svg", []byte(""), sk(key)) want(t, "wrong type", rec, http.StatusUnprocessableEntity) if d := errorDetails(t, rec)["photos"]; len(d) != 1 || d[0] != "The photos must be a file of type: jpg, jpeg, png, gif, webp." { t.Fatalf("type message %v", d) } rec = env.a.upload(t, photosPath(0, ""), "fake.png", []byte("GIF89a"), map[string]string{cabana.SessionKeyHeader: key, "Accept-Language": "pl"}) want(t, "polyglot", rec, http.StatusUnprocessableEntity) if d := errorDetails(t, rec)["photos"]; len(d) != 1 || d[0] != "photos musi być obrazkiem." { t.Fatalf("localized image message %v", d) } want(t, "no key", env.a.upload(t, photosPath(0, ""), "a.png", png, nil), http.StatusUnprocessableEntity) want(t, "unknown field", env.a.upload(t, dfPath(0, "/files/nothere"), "a.png", png, sk(key)), http.StatusNotFound) env.unchanged(t, "refused uploads", before) for i := 2; i <= 3; i++ { want(t, fmt.Sprintf("upload %d", i), env.a.upload(t, photosPath(0, ""), fmt.Sprintf("%d.png", i), png, sk(key)), http.StatusCreated) } rec = env.a.upload(t, photosPath(0, ""), "4.png", png, sk(key)) want(t, "fourth upload over maxFiles", rec, http.StatusUnprocessableEntity) if d := errorDetails(t, rec)["photos"]; len(d) != 1 || d[0] != "The photos may not have more than 3 items." { t.Fatalf("maxFiles message %v", d) } items := fileItems(t, env.a, 0, "photos", key) if len(items) != 3 || !slices.IsSortedFunc(items, func(a, b cabana.FileItem) int { return a.SortOrder - b.SortOrder }) { t.Fatalf("pending list = %+v", items) } for _, it := range items { if !it.Pending || it.URL == "" || it.ThumbURL == "" { t.Fatalf("pending public item %+v", it) } } // After the save the same files are attached and no longer pending. saved := env.a.do(t, http.MethodPost, "/acme/deferred/gadgets", map[string]any{"name": "g-" + env.stamp}, sk(key)) want(t, "save", saved, http.StatusCreated) g := dataID(t, saved.Body.Bytes()) attached := fileItems(t, env.a, g, "photos", "") if fmt.Sprint(itemIDs(attached)) != fmt.Sprint(itemIDs(items)) { t.Fatalf("attached %v, pending was %v", itemIDs(attached), itemIDs(items)) } for _, it := range attached { if it.Pending { t.Fatalf("attached item still pending %+v", it) } } // A new session sees the attached files plus its own pending upload. k2 := newSessionKey(t) want(t, "upload on a full field", env.a.upload(t, photosPath(g, ""), "5.png", png, sk(k2)), http.StatusUnprocessableEntity) first := attached[0].ID want(t, "remove one", env.a.do(t, http.MethodDelete, photosPath(g, fmt.Sprintf("/%d", first)), nil, sk(k2)), http.StatusOK) up = env.a.upload(t, photosPath(g, ""), "5.png", png, sk(k2)) want(t, "upload after a deferred removal", up, http.StatusCreated) mixed := fileItems(t, env.a, g, "photos", k2) if len(mixed) != 3 || mixed[2].ID != dataID(t, up.Body.Bytes()) || !mixed[2].Pending || mixed[0].Pending || slices.Contains(itemIDs(mixed), first) { t.Fatalf("session list = %+v", mixed) } if got := fileItems(t, env.a, g, "photos", ""); len(got) != 3 || !slices.Contains(itemIDs(got), first) { t.Fatalf("list without the session = %v", itemIDs(got)) } } // TestFileuploadRemove: removing a pending upload deletes its row and blob // at once; removing an attached file is deferred to the save, which then // deletes row and blob after commit. func TestFileuploadRemove(t *testing.T) { env := newDeferredEnv(t) g := env.gadget(t, "g-"+env.stamp, false) attached := env.storeFile(t, dfGadgetMorph, g, "photos", "a.png", conformPNG(t), true) key := newSessionKey(t) up := env.a.upload(t, photosPath(g, ""), "p.png", conformPNG(t), sk(key)) want(t, "upload", up, http.StatusCreated) pending, _ := env.fileRow(t, dataID(t, up.Body.Bytes())) want(t, "remove pending", env.a.do(t, http.MethodDelete, photosPath(g, fmt.Sprintf("/%d", pending.ID)), nil, sk(key)), http.StatusOK) if _, ok := env.fileRow(t, pending.ID); ok || env.blob(t, pending) { t.Fatal("removed pending upload kept its row or blob") } want(t, "remove attached", env.a.do(t, http.MethodDelete, photosPath(g, fmt.Sprintf("/%d", attached.ID)), nil, sk(key)), http.StatusOK) want(t, "remove attached again", env.a.do(t, http.MethodDelete, photosPath(g, fmt.Sprintf("/%d", attached.ID)), nil, sk(key)), http.StatusOK) if _, ok := env.fileRow(t, attached.ID); !ok || !env.blob(t, attached) { t.Fatal("a deferred removal deleted the file before the save") } if got := fileItems(t, env.a, g, "photos", key); len(got) != 0 { t.Fatalf("session still lists %v", itemIDs(got)) } want(t, "save", env.a.do(t, http.MethodPut, dfPath(g, ""), map[string]any{"name": "g-" + env.stamp}, sk(key)), http.StatusOK) if _, ok := env.fileRow(t, attached.ID); ok || env.blob(t, attached) { t.Fatal("the save kept the removed file or its blob") } if n := len(env.state(t).Bindings); n != 0 { t.Fatalf("bindings left %d", n) } want(t, "remove without a key", env.a.do(t, http.MethodDelete, photosPath(g, "/1"), nil, nil), http.StatusUnprocessableEntity) } // TestFileuploadCaption: a field without useCaption refuses caption edits // (403); with it the title and description are saved at once, not // deferred, and are limited in length. func TestFileuploadCaption(t *testing.T) { env := newDeferredEnv(t) g := env.gadget(t, "g-"+env.stamp, false) photo := env.storeFile(t, dfGadgetMorph, g, "photos", "a.png", conformPNG(t), true) manual := env.storeFile(t, dfGadgetMorph, g, "manual", "m.txt", []byte("manual"), false) key := newSessionKey(t) want(t, "caption without useCaption", env.a.do(t, http.MethodPut, photosPath(g, fmt.Sprintf("/%d", photo.ID)), map[string]any{"title": "x"}, sk(key)), http.StatusForbidden) rec := env.a.do(t, http.MethodPut, manualPath(g, manual.ID, ""), map[string]any{"title": "Manual", "description": "The full manual"}, sk(key)) want(t, "caption", rec, http.StatusOK) f, _ := env.fileRow(t, manual.ID) if strOrNil(f.Title) != "Manual" || strOrNil(f.Description) != "The full manual" { t.Fatalf("caption not saved at once: %+v", f) } want(t, "caption with an unknown key", env.a.do(t, http.MethodPut, manualPath(g, manual.ID, ""), map[string]any{"title": "x", "sort_order": 1}, sk(key)), http.StatusUnprocessableEntity) } // TestFileuploadReorder: the id set must equal the visible files exactly // (422 otherwise), the new order is written to sort_order at once, and an // attachOne field has no reorder. func TestFileuploadReorder(t *testing.T) { env := newDeferredEnv(t) g := env.gadget(t, "g-"+env.stamp, false) a := env.storeFile(t, dfGadgetMorph, g, "photos", "a.png", conformPNG(t), true) b := env.storeFile(t, dfGadgetMorph, g, "photos", "b.png", conformPNG(t), true) c := env.storeFile(t, dfGadgetMorph, g, "photos", "c.png", conformPNG(t), true) key := newSessionKey(t) for name, ids := range map[string][]uint{ "missing one": {a.ID, b.ID}, "extra id": {a.ID, b.ID, c.ID, c.ID + 100}, "duplicate id": {a.ID, a.ID, b.ID}, "empty": {}, } { before := env.state(t) want(t, "reorder "+name, env.a.do(t, http.MethodPost, photosPath(g, "/reorder"), map[string]any{"ids": ids}, sk(key)), http.StatusUnprocessableEntity) env.unchanged(t, "reorder "+name, before) } rec := env.a.do(t, http.MethodPost, photosPath(g, "/reorder"), map[string]any{"ids": []uint{c.ID, a.ID, b.ID}}, sk(key)) want(t, "reorder", rec, http.StatusOK) if got := itemIDs(fileItems(t, env.a, g, "photos", "")); fmt.Sprint(got) != fmt.Sprint([]uint{c.ID, a.ID, b.ID}) { t.Fatalf("order = %v", got) } // The permutation reuses the existing sort_order values. var orders []int if err := env.db.Model(&attach.File{}).Where("id IN ?", []uint{a.ID, b.ID, c.ID}).Order("sort_order").Pluck("sort_order", &orders).Error; err != nil { t.Fatal(err) } if fmt.Sprint(orders) != fmt.Sprint([]int{a.SortOrder, b.SortOrder, c.SortOrder}) { t.Fatalf("sort orders %v, want the permutation of %d %d %d", orders, a.SortOrder, b.SortOrder, c.SortOrder) } m := env.storeFile(t, dfGadgetMorph, g, "manual", "m.txt", []byte("m"), false) rec = env.a.do(t, http.MethodPost, dfPath(g, "/files/manual/reorder"), map[string]any{"ids": []uint{m.ID}}, sk(key)) if rec.Code/100 == 2 { t.Fatalf("attachOne reorder status=%d", rec.Code) } } // TestFileuploadAttachOneReplace: two uploads into an attachOne field in // one session leave the last one attached at the save; the replaced file // and the file attached before lose their rows and blobs after commit. func TestFileuploadAttachOneReplace(t *testing.T) { env := newDeferredEnv(t) g := env.gadget(t, "g-"+env.stamp, false) old := env.storeFile(t, dfGadgetMorph, g, "manual", "old.txt", []byte("old"), false) key := newSessionKey(t) upA := env.a.upload(t, dfPath(g, "/files/manual"), "a.txt", []byte("a"), sk(key)) upB := env.a.upload(t, dfPath(g, "/files/manual"), "b.txt", []byte("b"), sk(key)) want(t, "upload a", upA, http.StatusCreated) want(t, "upload b", upB, http.StatusCreated) a, _ := env.fileRow(t, dataID(t, upA.Body.Bytes())) want(t, "save", env.a.do(t, http.MethodPut, dfPath(g, ""), map[string]any{"name": "g-" + env.stamp}, sk(key)), http.StatusOK) got := fileItems(t, env.a, g, "manual", "") if len(got) != 1 || got[0].ID != dataID(t, upB.Body.Bytes()) || got[0].URL != "" { t.Fatalf("attachOne after save = %+v", got) } for _, f := range []attach.File{old, a} { if _, ok := env.fileRow(t, f.ID); ok || env.blob(t, f) { t.Fatalf("replaced file %s kept its row or blob", f.FileName) } } } // TestFileuploadMIME: a mimeTypes list refuses an allowed extension whose // sniffed type is not listed (422 on the field). func TestFileuploadMIME(t *testing.T) { fields := strings.Replace(dfFile(t, "models/gadget/fields.yaml"), " fileTypes: [pdf, png, svg, txt, html]\n", " fileTypes: [pdf, png, svg, txt, html]\n mimeTypes: [application/pdf, text/plain]\n", 1) env := newDeferredEnvWith(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/gadget/fields.yaml": fields})}) g := env.gadget(t, "g-"+env.stamp, false) key := newSessionKey(t) rec := env.a.upload(t, dfPath(g, "/files/manual"), "a.png", conformPNG(t), sk(key)) want(t, "png under a pdf/text list", rec, http.StatusUnprocessableEntity) if d := errorDetails(t, rec)["manual"]; len(d) != 1 || d[0] != "The manual must be a file of type: application/pdf, text/plain." { t.Fatalf("mime message %v", d) } want(t, "text", env.a.upload(t, dfPath(g, "/files/manual"), "a.txt", []byte("hello"), sk(key)), http.StatusCreated) } // TestFileuploadUploadID: a repeated X-Upload-Id returns the stored file // instead of creating a second binding (CR-02). func TestFileuploadUploadID(t *testing.T) { env := newDeferredEnv(t) key := newSessionKey(t) h := map[string]string{cabana.SessionKeyHeader: key, "X-Upload-Id": "retry-one"} first := env.a.upload(t, photosPath(0, ""), "a.png", conformPNG(t), h) want(t, "first upload", first, http.StatusCreated) id := dataID(t, first.Body.Bytes()) again := env.a.upload(t, photosPath(0, ""), "b.png", conformPNG(t), h) want(t, "same upload id", again, http.StatusCreated) if got := dataID(t, again.Body.Bytes()); got != id { t.Fatalf("retry created %d, want %d", got, id) } if got := fileItems(t, env.a, 0, "photos", key); len(got) != 1 || got[0].ID != id { t.Fatalf("list = %+v", got) } } // TestJSONBodyCaps: create, update, link, unlink and settings refuse a // JSON body past http.body_limits.default_bytes (CR-03). func TestJSONBodyCaps(t *testing.T) { env := newDeferredEnv(t) huge := strings.Repeat("x", 1100<<10) want(t, "create", env.a.do(t, http.MethodPost, "/acme/deferred/gadgets", map[string]any{"name": huge}, nil), http.StatusRequestEntityTooLarge) g := env.gadget(t, "g-"+env.stamp, false) want(t, "update", env.a.do(t, http.MethodPut, dfPath(g, ""), map[string]any{"name": huge}, nil), http.StatusRequestEntityTooLarge) want(t, "link", env.a.do(t, http.MethodPost, dfPath(g, "/relations/members/link"), map[string]any{"ids": []uint{1}, "pad": huge}, nil), http.StatusRequestEntityTooLarge) want(t, "unlink", env.a.do(t, http.MethodPost, dfPath(g, "/relations/members/unlink"), map[string]any{"ids": []uint{1}, "pad": huge}, nil), http.StatusRequestEntityTooLarge) settings := newConformEnv(t) settings.send(t, http.MethodPost, "/auth/login", map[string]string{"login": settings.login, "password": adminTestPassword}, false) rec := settings.send(t, http.MethodPut, "/settings/conform", map[string]any{"enabled": true, "pad": huge}, true) if rec.Code != http.StatusRequestEntityTooLarge { t.Fatalf("settings: status=%d want %d body=%s", rec.Code, http.StatusRequestEntityTooLarge, rec.Body.String()) } }