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