- 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
309 lines
13 KiB
Go
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)
|
|
}
|
|
}
|