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
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -9,6 +11,8 @@ import (
|
||||
|
||||
"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 {
|
||||
@@ -116,3 +120,189 @@ func TestFileuploadSmokeForeignAdmin(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user