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:
Jakub Zych
2026-10-02 18:11:56 +02:00
parent 044e0450ef
commit e54fd257ee
17 changed files with 2237 additions and 82 deletions

View File

@@ -45,6 +45,22 @@ type conformCase struct {
ref string
call func(t *testing.T, env *conformEnv) *httptest.ResponseRecorder
decode func(dec *json.Decoder) error
// raw, when set, checks a binary response instead of decoding JSON;
// admin.json must document the success as binary.
raw func(t *testing.T, rec *httptest.ResponseRecorder)
}
// binaryFile checks a protected file response: status, content type and
// the D-10 headers.
func binaryFile(contentType string) func(t *testing.T, rec *httptest.ResponseRecorder) {
return func(t *testing.T, rec *httptest.ResponseRecorder) {
t.Helper()
h := rec.Header()
if h.Get("Content-Type") != contentType || h.Get("X-Content-Type-Options") != "nosniff" ||
h.Get("Cache-Control") != "private, no-store" || !strings.Contains(h.Get("Content-Security-Policy"), "sandbox") || rec.Body.Len() == 0 {
t.Fatalf("protected file headers=%v len=%d", h, rec.Body.Len())
}
}
}
func into[T any]() func(*json.Decoder) error {
@@ -66,33 +82,33 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
cases := []conformCase{
{"POST /auth/login", 200, "cabana.Envelope-cabana_AdminLoginData", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPost, "/auth/login", map[string]string{"login": e.login, "password": adminTestPassword}, false)
}, into[cabana.Envelope[cabana.AdminLoginData]]()},
}, into[cabana.Envelope[cabana.AdminLoginData]](), nil},
{"POST /auth/refresh", 200, "cabana.Envelope-cabana_AdminLoginData", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
rec := e.send(t, http.MethodPost, "/auth/refresh", nil, true)
e.token = accessToken(t, rec.Body.Bytes())
return rec
}, into[cabana.Envelope[cabana.AdminLoginData]]()},
}, into[cabana.Envelope[cabana.AdminLoginData]](), nil},
{"GET /auth/me", 200, "cabana.Envelope-cabana_AdminProfile", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/auth/me", nil, true)
}, into[cabana.Envelope[cabana.AdminProfile]]()},
}, into[cabana.Envelope[cabana.AdminProfile]](), nil},
{"GET /lang", 200, "cabana.Envelope-cabana_LangBundle", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/lang", nil, false)
}, into[cabana.Envelope[cabana.LangBundle]]()},
}, into[cabana.Envelope[cabana.LangBundle]](), nil},
{"GET /navigation", 200, "cabana.Envelope-array_cabana_NavigationEntry", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/navigation", nil, true)
}, into[cabana.Envelope[[]cabana.NavigationEntry]]()},
}, into[cabana.Envelope[[]cabana.NavigationEntry]](), nil},
{"GET /settings", 200, "cabana.Envelope-array_cabana_SettingsEntry", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/settings", nil, true)
}, into[cabana.Envelope[[]cabana.SettingsEntry]]()},
}, into[cabana.Envelope[[]cabana.SettingsEntry]](), nil},
{"GET /settings/{code}/schema", 200, "cabana.Envelope-cabana_FormView", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/settings/conform/schema", nil, true)
}, into[cabana.Envelope[cabana.FormView]]()},
}, into[cabana.Envelope[cabana.FormView]](), nil},
{"GET /settings/{code}", 200, "cabana.Envelope-cabana_SettingsResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/settings/conform", nil, true)
}, into[cabana.Envelope[cabana.SettingsResult]]()},
}, into[cabana.Envelope[cabana.SettingsResult]](), nil},
{"PUT /settings/{code}", 200, "cabana.Envelope-cabana_SettingsResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPut, "/settings/conform", map[string]any{"enabled": true}, true)
}, into[cabana.Envelope[cabana.SettingsResult]]()},
}, into[cabana.Envelope[cabana.SettingsResult]](), nil},
{"GET /{vendor}/{plugin}/{controller}/schema/list", 200, "cabana.Envelope-cabana_ListSchema", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
rec := e.send(t, http.MethodGet, "/acme/conform/gadgets/schema/list", nil, true)
var body cabana.Envelope[cabana.ListSchema]
@@ -108,22 +124,24 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
e.assertPluginAsset(t, body.Data.Assets.Scripts[0], "text/javascript; charset=utf-8")
e.assertPluginAsset(t, body.Data.Assets.Styles[0], "text/css; charset=utf-8")
return rec
}, into[cabana.Envelope[cabana.ListSchema]]()},
}, into[cabana.Envelope[cabana.ListSchema]](), nil},
{"GET /{vendor}/{plugin}/{controller}/schema/form", 200, "cabana.Envelope-cabana_FormView", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/acme/conform/gadgets/schema/form", nil, true)
}, into[cabana.Envelope[cabana.FormView]]()},
}, into[cabana.Envelope[cabana.FormView]](), nil},
{"GET /{vendor}/{plugin}/{controller}/schema/relation/{name}", 200, "cabana.Envelope-cabana_RelationSchema", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/acme/conform/gadgets/schema/relation/members", nil, true)
}, into[cabana.Envelope[cabana.RelationSchema]]()},
}, into[cabana.Envelope[cabana.RelationSchema]](), nil},
{"GET /{vendor}/{plugin}/{controller}/fields/{field}/options", 200, "cabana.ListEnvelope-array_cabana_RelationOption", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/acme/conform/gadgets/fields/group/options?search="+e.stamp, nil, true)
}, into[cabana.ListEnvelope[[]cabana.RelationOption]]()},
}, into[cabana.ListEnvelope[[]cabana.RelationOption]](), nil},
{"GET /{vendor}/{plugin}/{controller}/filters/{scope}/options", 200, "cabana.Envelope-array_cabana_FilterOption", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/acme/conform/gadgets/filters/grouped/options", nil, true)
}, into[cabana.Envelope[[]cabana.FilterOption]]()},
}, into[cabana.Envelope[[]cabana.FilterOption]](), nil},
{"POST /{vendor}/{plugin}/{controller}/{id}/files/{field}", 201, "cabana.Envelope-cabana_FileItem", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.upload(t, 0, "photos", "photo.png", conformPNG(t), e.sessionKey)
}, into[cabana.Envelope[cabana.FileItem]]()},
rec := e.upload(t, 0, "photos", "photo.png", conformPNG(t), e.sessionKey)
e.photoID = dataID(t, rec.Body.Bytes())
return rec
}, into[cabana.Envelope[cabana.FileItem]](), nil},
{"GET /{vendor}/{plugin}/{controller}/{id}/files/{field}", 200, "cabana.Envelope-array_cabana_FileItem", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
rec := e.sendWith(t, http.MethodGet, "/acme/conform/gadgets/0/files/photos", nil, "", map[string]string{cabana.SessionKeyHeader: e.sessionKey})
var body cabana.Envelope[[]cabana.FileItem]
@@ -131,12 +149,34 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
t.Fatalf("pending file list = %s (%v)", rec.Body.String(), err)
}
return rec
}, into[cabana.Envelope[[]cabana.FileItem]]()},
}, into[cabana.Envelope[[]cabana.FileItem]](), nil},
{"POST /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder", 200, "cabana.Envelope-array_cabana_FileItem", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
body, _ := json.Marshal(map[string]any{"ids": []uint{e.photoID}})
return e.sendWith(t, http.MethodPost, "/acme/conform/gadgets/0/files/photos/reorder", body, "application/json", map[string]string{cabana.SessionKeyHeader: e.sessionKey})
}, into[cabana.Envelope[[]cabana.FileItem]](), nil},
{"DELETE /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}", 200, "cabana.Envelope-cabana_FileMutationResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.sendWith(t, http.MethodDelete, fmt.Sprintf("/acme/conform/gadgets/0/files/photos/%d", e.photoID), nil, "", map[string]string{cabana.SessionKeyHeader: e.sessionKey})
}, into[cabana.Envelope[cabana.FileMutationResult]](), nil},
{"POST /{vendor}/{plugin}/{controller}", 201, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
rec := e.send(t, http.MethodPost, "/acme/conform/gadgets", map[string]any{"name": "gadget-" + e.stamp, "active": true, "group": e.groupID}, true)
e.gadgetID = dataID(t, rec.Body.Bytes())
return rec
}, into[cabana.RecordEnvelope]()},
}, into[cabana.RecordEnvelope](), nil},
{"PUT /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}", 200, "cabana.Envelope-cabana_FileItem", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
up := e.upload(t, e.gadgetID, "manual", "manual.png", conformPNG(t), e.sessionKey)
if up.Code != http.StatusCreated {
t.Fatalf("manual upload status=%d body=%s", up.Code, up.Body.String())
}
e.manualID = dataID(t, up.Body.Bytes())
body, _ := json.Marshal(map[string]any{"title": "Manual " + e.stamp})
return e.sendWith(t, http.MethodPut, fmt.Sprintf("/acme/conform/gadgets/%d/files/manual/%d", e.gadgetID, e.manualID), body, "application/json", map[string]string{cabana.SessionKeyHeader: e.sessionKey})
}, into[cabana.Envelope[cabana.FileItem]](), nil},
{"GET /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/download", 200, "", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.sendWith(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/files/manual/%d/download", e.gadgetID, e.manualID), nil, "", map[string]string{cabana.SessionKeyHeader: e.sessionKey})
}, nil, binaryFile("image/png")},
{"GET /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/thumb", 200, "", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.sendWith(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/files/manual/%d/thumb", e.gadgetID, e.manualID), nil, "", map[string]string{cabana.SessionKeyHeader: e.sessionKey})
}, nil, binaryFile("image/png")},
{"POST /{vendor}/{plugin}/{controller}/widgets/{field}", 200, "cabana.Envelope-cabana_AdminActionResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
rec := e.send(t, http.MethodPost, "/acme/conform/gadgets/widgets/lookup", map[string]any{"record_id": e.gadgetID, "values": map[string]any{"name": "x", "active": false}}, true)
// The fixture action also returns active, which is outside the
@@ -149,7 +189,7 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
t.Fatalf("widget fill = %#v, want only name", body.Data.Fill)
}
return rec
}, into[cabana.Envelope[cabana.AdminActionResult]]()},
}, into[cabana.Envelope[cabana.AdminActionResult]](), nil},
{"GET /{vendor}/{plugin}/{controller}/partials/{name}", 200, "cabana.Envelope-cabana_PartialView", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
rec := e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/partials/summary?id=%d", e.gadgetID), nil, true)
if !strings.Contains(rec.Body.String(), `"text":"gadget-`+e.stamp+`"`) {
@@ -160,41 +200,41 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
t.Fatalf("header partial status=%d body=%s", header.Code, header.Body.String())
}
return rec
}, into[cabana.Envelope[cabana.PartialView]]()},
}, into[cabana.Envelope[cabana.PartialView]](), nil},
{"GET /{vendor}/{plugin}/{controller}", 200, "cabana.ListEnvelope-array_cabana_AdminRecord", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/acme/conform/gadgets?search="+e.stamp, nil, true)
}, into[cabana.ListEnvelope[[]cabana.AdminRecord]]()},
}, into[cabana.ListEnvelope[[]cabana.AdminRecord]](), nil},
{"GET /{vendor}/{plugin}/{controller}/{id}", 200, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), nil, true)
}, into[cabana.RecordEnvelope]()},
}, into[cabana.RecordEnvelope](), nil},
{"PUT /{vendor}/{plugin}/{controller}/{id}", 200, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPut, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), map[string]any{"name": "gadget-" + e.stamp + "-renamed", "group": nil}, true)
}, into[cabana.RecordEnvelope]()},
}, into[cabana.RecordEnvelope](), nil},
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", 200, "cabana.ListEnvelope-array_cabana_AdminRecord", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/candidates?search=%s", e.gadgetID, e.stamp), nil, true)
}, into[cabana.ListEnvelope[[]cabana.AdminRecord]]()},
}, into[cabana.ListEnvelope[[]cabana.AdminRecord]](), nil},
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", 200, "cabana.Envelope-cabana_RelationMutationResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/link", e.gadgetID), map[string]any{"ids": []uint{e.memberID}}, true)
}, into[cabana.Envelope[cabana.RelationMutationResult]]()},
}, into[cabana.Envelope[cabana.RelationMutationResult]](), nil},
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}", 200, "cabana.ListEnvelope-array_cabana_AdminRecord", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members", e.gadgetID), nil, true)
}, into[cabana.ListEnvelope[[]cabana.AdminRecord]]()},
}, into[cabana.ListEnvelope[[]cabana.AdminRecord]](), nil},
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", 200, "cabana.Envelope-cabana_RelationMutationResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/unlink", e.gadgetID), map[string]any{"ids": []uint{e.memberID}}, true)
}, into[cabana.Envelope[cabana.RelationMutationResult]]()},
}, into[cabana.Envelope[cabana.RelationMutationResult]](), nil},
{"POST /{vendor}/{plugin}/{controller}/toolbar/{action}", 200, "cabana.Envelope-cabana_AdminActionResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPost, "/acme/conform/gadgets/toolbar/recount", map[string]any{}, true)
}, into[cabana.Envelope[cabana.AdminActionResult]]()},
}, into[cabana.Envelope[cabana.AdminActionResult]](), nil},
{"POST /{vendor}/{plugin}/{controller}/bulk-delete", 200, "cabana.Envelope-cabana_BulkResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
spare := e.send(t, http.MethodPost, "/acme/conform/gadgets", map[string]any{"name": "spare-" + e.stamp}, true)
return e.send(t, http.MethodPost, "/acme/conform/gadgets/bulk-delete", map[string]any{"ids": []uint{dataID(t, spare.Body.Bytes())}}, true)
}, into[cabana.Envelope[cabana.BulkResult]]()},
}, into[cabana.Envelope[cabana.BulkResult]](), nil},
{"DELETE /{vendor}/{plugin}/{controller}/{id}", 200, "cabana.Envelope-cabana_BulkResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodDelete, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), nil, true)
}, into[cabana.Envelope[cabana.BulkResult]]()},
}, into[cabana.Envelope[cabana.BulkResult]](), nil},
{"POST /auth/logout", 200, "cabana.Envelope-cabana_AdminLogoutData", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPost, "/auth/logout", nil, true)
}, into[cabana.Envelope[cabana.AdminLogoutData]]()},
}, into[cabana.Envelope[cabana.AdminLogoutData]](), nil},
}
inventory := map[string]bool{}
@@ -223,12 +263,19 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
if rec.Code != tc.status {
t.Fatalf("status=%d want %d body=%s", rec.Code, tc.status, rec.Body.String())
}
method, path, _ := strings.Cut(tc.key, " ")
if tc.raw != nil {
tc.raw(t, rec)
if !spec.binary(path, strings.ToLower(method), fmt.Sprint(tc.status)) {
t.Fatalf("admin.json does not document %s %d as binary", tc.key, tc.status)
}
return
}
dec := json.NewDecoder(bytes.NewReader(rec.Body.Bytes()))
dec.DisallowUnknownFields()
if err := tc.decode(dec); err != nil {
t.Fatalf("body does not match its documented type: %v\n%s", err, rec.Body.String())
}
method, path, _ := strings.Cut(tc.key, " ")
got := spec.ref(path, strings.ToLower(method), fmt.Sprint(tc.status))
if got != tc.ref {
t.Fatalf("admin.json documents %q for %s %d, the handler writes %s", got, tc.key, tc.status, tc.ref)
@@ -245,13 +292,20 @@ type conformSpecDoc struct {
Responses map[string]struct {
Content map[string]struct {
Schema struct {
Ref string `json:"$ref"`
Ref string `json:"$ref"`
Type string `json:"type"`
Format string `json:"format"`
} `json:"schema"`
} `json:"content"`
} `json:"responses"`
} `json:"paths"`
}
func (d conformSpecDoc) binary(path, method, status string) bool {
schema := d.Paths[path][method].Responses[status].Content["application/octet-stream"].Schema
return schema.Type == "string" && schema.Format == "binary"
}
func (d conformSpecDoc) ref(path, method, status string) string {
ref := d.Paths[path][method].Responses[status].Content["application/json"].Schema.Ref
return strings.TrimPrefix(ref, "#/components/schemas/")
@@ -282,6 +336,8 @@ type conformEnv struct {
token string
stamp string
sessionKey string
photoID uint
manualID uint
groupID uint
memberID uint
gadgetID uint
@@ -491,7 +547,7 @@ type conformGadget struct {
func (conformGadget) TableName() string { return "cabana_conform_gadgets" }
func (conformGadget) MorphName() string { return "acme.conform.gadget" }
func (conformGadget) AttachRelations() []attach.Relation {
return []attach.Relation{{Name: "photos", Many: true, Public: true}}
return []attach.Relation{{Name: "photos", Many: true, Public: true}, {Name: "manual"}}
}
func (conformGadget) Fillable() []string { return []string{"name", "active"} }
func (conformGadget) Rules() map[string]string { return map[string]string{"name": "required"} }
@@ -733,6 +789,12 @@ update:
maxFilesize: 0.5
thumbOptions:
mode: crop
manual:
label: Manual
type: fileupload
fileTypes: [pdf, png, svg, txt]
useCaption: true
context: update
`),
"models/settings/fields.yaml": file(`fields:
enabled: