Files
summercms/modules/cabana/fileupload_smoke_test.go
Jakub Zych e54fd257ee feat(12.2-02): add file removal, caption, reorder and protected downloads
- DELETE, PUT and POST reorder under .../{id}/files/{field}, each scoped by one parent query (404 for a foreign file)
- protected download and thumb routes: is_public=false only, nosniff, private no-store, sandbox CSP, inline only for jpeg/png/gif/webp
- the save applies deferred removals, replaces attachOne files and rechecks maxFiles and required
- blobs of deleted files are removed after commit
- swagger2openapi emits binary content for file responses
- admin OpenAPI, TS types, conformance, README and attachments docs
2026-10-02 18:11:56 +02:00

309 lines
13 KiB
Go

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(`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`)
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)
}
}