package cabana_test import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "testing" "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/lagoon/attach" "gorm.io/gorm" ) func fileList(t *testing.T, rec *httptest.ResponseRecorder) []cabana.FileItem { t.Helper() if rec.Code != http.StatusOK { t.Fatalf("file list status=%d body=%s", rec.Code, rec.Body.String()) } var body cabana.Envelope[[]cabana.FileItem] if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("file list: %v\n%s", err, rec.Body.String()) } return body.Data } func (e *conformEnv) listFiles(t *testing.T, id uint, field, key string) []cabana.FileItem { t.Helper() headers := map[string]string{} if key != "" { headers[cabana.SessionKeyHeader] = key } return fileList(t, e.sendWith(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/files/%s", id, field), nil, "", headers)) } func (e *conformEnv) loginAs(t *testing.T, login string) { t.Helper() e.login = login if rec := e.send(t, http.MethodPost, "/auth/login", map[string]string{"login": login, "password": adminTestPassword}, false); rec.Code != http.StatusOK { t.Fatalf("login %s status=%d body=%s", login, rec.Code, rec.Body.String()) } } func (e *conformEnv) bindingCount(t *testing.T, key string) int64 { t.Helper() var n int64 if err := e.db.Model(&lagoon.DeferredBinding{}).Where("session_key = ?", key).Count(&n).Error; err != nil { t.Fatal(err) } return n } // TestFileuploadSmokeCreateCommit uploads an image to the create form (id 0) // and saves the record with the same session key: the file is attached to // the new record and the binding is gone. func TestFileuploadSmokeCreateCommit(t *testing.T) { env := newConformEnv(t) env.loginAs(t, env.login) key := newSessionKey(t) up := env.upload(t, 0, "photos", "photo.png", conformPNG(t), key) if up.Code != http.StatusCreated { t.Fatalf("upload status=%d body=%s", up.Code, up.Body.String()) } if got := env.listFiles(t, 0, "photos", key); len(got) != 1 || !got[0].Pending { t.Fatalf("pending list = %#v", got) } payload, _ := json.Marshal(map[string]any{"name": "smoke-" + env.stamp}) created := env.sendWith(t, http.MethodPost, "/acme/conform/gadgets", payload, "application/json", map[string]string{cabana.SessionKeyHeader: key}) if created.Code != http.StatusCreated { t.Fatalf("create status=%d body=%s", created.Code, created.Body.String()) } id := dataID(t, created.Body.Bytes()) got := env.listFiles(t, id, "photos", "") if len(got) != 1 || got[0].Pending || got[0].FileName != "photo.png" || got[0].URL == "" { t.Fatalf("attached list = %#v", got) } if n := env.bindingCount(t, key); n != 0 { t.Fatalf("deferred_bindings rows for the key after save = %d", n) } // Without the key, the unsaved form shows nothing: id 0 is 404. if rec := env.sendWith(t, http.MethodGet, "/acme/conform/gadgets/0/files/photos", nil, "", nil); rec.Code != http.StatusNotFound { t.Fatalf("id 0 without key status=%d", rec.Code) } // A malformed key is a 422 on session_key. if rec := env.sendWith(t, http.MethodGet, "/acme/conform/gadgets/0/files/photos", nil, "", map[string]string{cabana.SessionKeyHeader: "short"}); rec.Code != http.StatusUnprocessableEntity { t.Fatalf("malformed key status=%d", rec.Code) } } // TestFileuploadSmokeForeignAdmin replays one admin's session key as another // admin: the pending upload is neither listed nor committed. func TestFileuploadSmokeForeignAdmin(t *testing.T) { env := newConformEnv(t) env.loginAs(t, env.login) key := newSessionKey(t) if up := env.upload(t, 0, "photos", "photo.png", conformPNG(t), key); up.Code != http.StatusCreated { t.Fatalf("upload status=%d body=%s", up.Code, up.Body.String()) } other := *env login := "other-" + env.stamp insertAdmin(t, env.db, login, login+"@example.test", adminTestPassword, true, false) other.loginAs(t, login) if got := other.listFiles(t, 0, "photos", key); len(got) != 0 { t.Fatalf("foreign admin sees %#v", got) } payload, _ := json.Marshal(map[string]any{"name": "foreign-" + env.stamp}) created := other.sendWith(t, http.MethodPost, "/acme/conform/gadgets", payload, "application/json", map[string]string{cabana.SessionKeyHeader: key}) if created.Code != http.StatusCreated { t.Fatalf("create status=%d body=%s", created.Code, created.Body.String()) } if got := other.listFiles(t, dataID(t, created.Body.Bytes()), "photos", ""); len(got) != 0 { t.Fatalf("foreign save attached %#v", got) } if n := env.bindingCount(t, key); n != 1 { t.Fatalf("owner's binding rows = %d, want 1", n) } } func (e *conformEnv) createGadget(t *testing.T, name, key string) uint { t.Helper() payload, _ := json.Marshal(map[string]any{"name": name}) headers := map[string]string{} if key != "" { headers[cabana.SessionKeyHeader] = key } rec := e.sendWith(t, http.MethodPost, "/acme/conform/gadgets", payload, "application/json", headers) if rec.Code != http.StatusCreated { t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String()) } return dataID(t, rec.Body.Bytes()) } func (e *conformEnv) saveGadget(t *testing.T, id uint, name, key string) *httptest.ResponseRecorder { t.Helper() payload, _ := json.Marshal(map[string]any{"name": name}) return e.sendWith(t, http.MethodPut, fmt.Sprintf("/acme/conform/gadgets/%d", id), payload, "application/json", map[string]string{cabana.SessionKeyHeader: key}) } func (e *conformEnv) storedFile(t *testing.T, id uint) (attach.File, bool) { t.Helper() var f attach.File err := e.db.Where("id = ?", id).Take(&f).Error if errors.Is(err, gorm.ErrRecordNotFound) { return attach.File{}, false } if err != nil { t.Fatal(err) } return f, true } func (e *conformEnv) blobExists(t *testing.T, diskName string) bool { t.Helper() ok, err := e.bucket.Exists(context.Background(), attach.BlobKey(diskName)) if err != nil { t.Fatal(err) } return ok } // TestFileuploadSmokeRemoveCancelsPending removes a pending upload: its // row is deleted and, after commit, its blob. func TestFileuploadSmokeRemoveCancelsPending(t *testing.T) { env := newConformEnv(t) env.loginAs(t, env.login) key := newSessionKey(t) up := env.upload(t, 0, "photos", "photo.png", conformPNG(t), key) if up.Code != http.StatusCreated { t.Fatalf("upload status=%d body=%s", up.Code, up.Body.String()) } id := dataID(t, up.Body.Bytes()) f, ok := env.storedFile(t, id) if !ok || !env.blobExists(t, f.DiskName) { t.Fatal("upload left no row or blob") } rec := env.sendWith(t, http.MethodDelete, fmt.Sprintf("/acme/conform/gadgets/0/files/photos/%d", id), nil, "", map[string]string{cabana.SessionKeyHeader: key}) if rec.Code != http.StatusOK { t.Fatalf("remove status=%d body=%s", rec.Code, rec.Body.String()) } if _, ok := env.storedFile(t, id); ok { t.Fatal("cancelled upload row still exists") } if env.blobExists(t, f.DiskName) { t.Fatal("cancelled upload blob still exists") } if n := env.bindingCount(t, key); n != 0 { t.Fatalf("bindings after cancel = %d", n) } // The same file again is out of scope. again := env.sendWith(t, http.MethodDelete, fmt.Sprintf("/acme/conform/gadgets/0/files/photos/%d", id), nil, "", map[string]string{cabana.SessionKeyHeader: key}) if again.Code != http.StatusNotFound { t.Fatalf("second remove status=%d", again.Code) } } // TestFileuploadSmokeAttachOneReplace saves a second file into an attachOne // field: the first file's row and blob are gone after the save, and a // deferred removal of an attached file applies on the next save. func TestFileuploadSmokeAttachOneReplace(t *testing.T) { env := newConformEnv(t) env.loginAs(t, env.login) gadget := env.createGadget(t, "replace-"+env.stamp, "") first := newSessionKey(t) upA := env.upload(t, gadget, "manual", "a.txt", []byte("first manual\n"), first) if upA.Code != http.StatusCreated { t.Fatalf("upload a status=%d body=%s", upA.Code, upA.Body.String()) } if rec := env.saveGadget(t, gadget, "replace-"+env.stamp, first); rec.Code != http.StatusOK { t.Fatalf("save a status=%d body=%s", rec.Code, rec.Body.String()) } a, _ := env.storedFile(t, dataID(t, upA.Body.Bytes())) if got := env.listFiles(t, gadget, "manual", ""); len(got) != 1 || got[0].ID != a.ID || got[0].URL != "" { t.Fatalf("after first save = %#v", got) } second := newSessionKey(t) upB := env.upload(t, gadget, "manual", "b.txt", []byte("second manual\n"), second) if upB.Code != http.StatusCreated { t.Fatalf("upload b status=%d body=%s", upB.Code, upB.Body.String()) } if rec := env.saveGadget(t, gadget, "replace-"+env.stamp, second); rec.Code != http.StatusOK { t.Fatalf("save b status=%d body=%s", rec.Code, rec.Body.String()) } got := env.listFiles(t, gadget, "manual", "") if len(got) != 1 || got[0].ID != dataID(t, upB.Body.Bytes()) { t.Fatalf("after replace = %#v", got) } if _, ok := env.storedFile(t, a.ID); ok { t.Fatal("replaced attachOne row still exists") } if env.blobExists(t, a.DiskName) { t.Fatal("replaced attachOne blob still exists") } // A deferred removal hides the file in the session and deletes it on save. third := newSessionKey(t) b, _ := env.storedFile(t, got[0].ID) if rec := env.sendWith(t, http.MethodDelete, fmt.Sprintf("/acme/conform/gadgets/%d/files/manual/%d", gadget, b.ID), nil, "", map[string]string{cabana.SessionKeyHeader: third}); rec.Code != http.StatusOK { t.Fatalf("remove status=%d body=%s", rec.Code, rec.Body.String()) } if len(env.listFiles(t, gadget, "manual", third)) != 0 || len(env.listFiles(t, gadget, "manual", "")) != 1 { t.Fatal("deferred removal is not scoped to its session") } if rec := env.saveGadget(t, gadget, "replace-"+env.stamp, third); rec.Code != http.StatusOK { t.Fatalf("save removal status=%d body=%s", rec.Code, rec.Body.String()) } if _, ok := env.storedFile(t, b.ID); ok || env.blobExists(t, b.DiskName) { t.Fatal("deferred removal did not delete the file and its blob") } } // TestProtectedFileSmoke downloads protected files through the admin route: // a file of another record is 404, a public file is 404, and an SVG stored // in file mode is an octet-stream attachment with nosniff. func TestProtectedFileSmoke(t *testing.T) { env := newConformEnv(t) env.loginAs(t, env.login) owner := env.createGadget(t, "owner-"+env.stamp, "") other := env.createGadget(t, "other-"+env.stamp, "") key := newSessionKey(t) svg := []byte(``) up := env.upload(t, owner, "manual", "logo one.svg", svg, key) if up.Code != http.StatusCreated { t.Fatalf("upload status=%d body=%s", up.Code, up.Body.String()) } if rec := env.saveGadget(t, owner, "owner-"+env.stamp, key); rec.Code != http.StatusOK { t.Fatalf("save status=%d body=%s", rec.Code, rec.Body.String()) } fileID := dataID(t, up.Body.Bytes()) rec := env.sendWith(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/files/manual/%d/download", owner, fileID), nil, "", nil) h := rec.Header() if rec.Code != http.StatusOK || h.Get("Content-Type") != "application/octet-stream" || h.Get("X-Content-Type-Options") != "nosniff" || h.Get("Content-Disposition") != "attachment; filename*=UTF-8''logo%20one.svg" || h.Get("Cache-Control") != "private, no-store" || h.Get("Content-Security-Policy") != "default-src 'none'; sandbox" || rec.Body.String() != string(svg) { t.Fatalf("svg download status=%d headers=%v body=%q", rec.Code, h, rec.Body.String()) } if thumb := env.sendWith(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/files/manual/%d/thumb", owner, fileID), nil, "", nil); thumb.Code != http.StatusNotFound { t.Fatalf("thumb of a non-image status=%d", thumb.Code) } for _, path := range []string{ fmt.Sprintf("/acme/conform/gadgets/%d/files/manual/%d/download", other, fileID), fmt.Sprintf("/acme/conform/gadgets/%d/files/photos/%d/download", owner, fileID), fmt.Sprintf("/acme/conform/gadgets/0/files/manual/%d/download", fileID), } { if rec := env.sendWith(t, http.MethodGet, path, nil, "", map[string]string{cabana.SessionKeyHeader: key}); rec.Code != http.StatusNotFound { t.Fatalf("%s status=%d, want 404", path, rec.Code) } } if rec := env.sendWith(t, http.MethodDelete, fmt.Sprintf("/acme/conform/gadgets/%d/files/manual/%d", other, fileID), nil, "", map[string]string{cabana.SessionKeyHeader: key}); rec.Code != http.StatusNotFound { t.Fatalf("remove through another record status=%d", rec.Code) } // A public file is never served by the protected route. pub := env.upload(t, owner, "photos", "photo.png", conformPNG(t), key) if pub.Code != http.StatusCreated { t.Fatalf("public upload status=%d body=%s", pub.Code, pub.Body.String()) } if rec := env.sendWith(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/files/photos/%d/download", owner, dataID(t, pub.Body.Bytes())), nil, "", map[string]string{cabana.SessionKeyHeader: key}); rec.Code != http.StatusNotFound { t.Fatalf("public file through the protected route status=%d", rec.Code) } }