- record id 0 with X-Session-Key manages deferrable relations: create, link, unlink, delete and pivot edits are held in deferred_bindings
- the record's create save applies relation bindings with the file bindings; an ineligible link is a 422 on the relation-manager field
- child forms upload files through .../records/{child}/files/{field} keyed by X-Child-Session-Key; the child save commits them
- boot refuses a deferrable relation with create whose related model no plugin lists in Models()
1001 lines
46 KiB
Go
1001 lines
46 KiB
Go
package cabana_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/png"
|
|
"io/fs"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
|
"git.golem15.com/golem15/summercms/modules/compass"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/party"
|
|
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
|
"git.golem15.com/golem15/summercms/modules/surf"
|
|
"gocloud.dev/blob"
|
|
"gocloud.dev/blob/memblob"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// conformCase is one inventoried admin route called for real through the
|
|
// assembled router. decode must accept the body with unknown fields
|
|
// disallowed, and ref is the success schema admin.json documents.
|
|
type conformCase struct {
|
|
key string
|
|
status int
|
|
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 {
|
|
return func(dec *json.Decoder) error {
|
|
var out T
|
|
return dec.Decode(&out)
|
|
}
|
|
}
|
|
|
|
// TestPhase10OpenAPIConformance calls every route of the admin inventory
|
|
// against a PostgreSQL-backed fixture registry (acme names only), decodes
|
|
// each real response into the Go type its annotation documents with unknown
|
|
// fields disallowed, and checks admin/openapi/admin.json names that type for
|
|
// the same path, method and status (D-15, D-16; Pitfall 8).
|
|
func TestPhase10OpenAPIConformance(t *testing.T) {
|
|
env := newConformEnv(t)
|
|
spec := conformSpec(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]](), 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]](), 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]](), 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]](), 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]](), 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]](), 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]](), 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]](), 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]](), 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]
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("list schema: %v", err)
|
|
}
|
|
if len(body.Data.ToolbarActions) != 1 || body.Data.ToolbarActions[0].Name != "recount" {
|
|
t.Fatalf("toolbarActions = %#v", body.Data.ToolbarActions)
|
|
}
|
|
if len(body.Data.Assets.Scripts) != 1 || len(body.Data.Assets.Styles) != 1 {
|
|
t.Fatalf("assets = %#v", body.Data.Assets)
|
|
}
|
|
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]](), 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]](), 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]](), 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]](), 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]](), nil},
|
|
{"POST /{vendor}/{plugin}/{controller}/{id}/files/{field}", 201, "cabana.Envelope-cabana_FileItem", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
|
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]
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || len(body.Data) != 1 || !body.Data[0].Pending || body.Data[0].URL == "" || body.Data[0].ThumbURL == "" {
|
|
t.Fatalf("pending file list = %s (%v)", rec.Body.String(), err)
|
|
}
|
|
return rec
|
|
}, 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, "released_on": "2026-10-02", "starts_at": "2026-10-02T12:30:00+02:00"}, true)
|
|
e.gadgetID = dataID(t, rec.Body.Bytes())
|
|
return rec
|
|
}, 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
|
|
// field's fill: the server filter must drop it.
|
|
var body cabana.Envelope[cabana.AdminActionResult]
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("widget action body: %v\n%s", err, rec.Body.String())
|
|
}
|
|
if len(body.Data.Fill) != 1 || body.Data.Fill["name"] != "lookup-"+e.stamp {
|
|
t.Fatalf("widget fill = %#v, want only name", body.Data.Fill)
|
|
}
|
|
return rec
|
|
}, 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+`"`) {
|
|
t.Fatalf("form partial did not render the scoped record: %s", rec.Body.String())
|
|
}
|
|
header := e.send(t, http.MethodGet, "/acme/conform/gadgets/partials/stats", nil, true)
|
|
if header.Code != http.StatusOK || !strings.Contains(header.Body.String(), `"class":"summer-stats"`) {
|
|
t.Fatalf("header partial status=%d body=%s", header.Code, header.Body.String())
|
|
}
|
|
return rec
|
|
}, 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]](), 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](), 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, "released_on": "2026-10-03", "starts_at": nil}, true)
|
|
}, 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]](), 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]](), nil},
|
|
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}", 200, "cabana.Envelope-cabana_AdminRecord", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
|
return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/pivot/%d", e.gadgetID, e.memberID), nil, true)
|
|
}, into[cabana.Envelope[cabana.AdminRecord]](), nil},
|
|
{"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}", 200, "cabana.Envelope-cabana_AdminRecord", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
|
return e.send(t, http.MethodPut, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/pivot/%d", e.gadgetID, e.memberID), map[string]any{"note": "note-" + e.stamp}, true)
|
|
}, into[cabana.Envelope[cabana.AdminRecord]](), 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]](), 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]](), nil},
|
|
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records", 201, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
|
rec := e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records", e.gadgetID), map[string]any{"label": "part-" + e.stamp}, true)
|
|
e.partID = dataID(t, rec.Body.Bytes())
|
|
return rec
|
|
}, into[cabana.RecordEnvelope](), nil},
|
|
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}", 200, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
|
return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records/%d", e.gadgetID, e.partID), nil, true)
|
|
}, into[cabana.RecordEnvelope](), nil},
|
|
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}", 201, "cabana.Envelope-cabana_FileItem", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
|
rec := e.childUpload(t, e.gadgetID, e.partID, "images", "part.png", conformPNG(t), e.childKey)
|
|
e.partFileID = dataID(t, rec.Body.Bytes())
|
|
return rec
|
|
}, into[cabana.Envelope[cabana.FileItem]](), nil},
|
|
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}", 200, "cabana.Envelope-array_cabana_FileItem", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
|
rec := e.sendWith(t, http.MethodGet, e.childFilePath(""), nil, "", map[string]string{cabana.ChildSessionKeyHeader: e.childKey})
|
|
var body cabana.Envelope[[]cabana.FileItem]
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || len(body.Data) != 1 || !body.Data[0].Pending || body.Data[0].URL != "" {
|
|
t.Fatalf("pending child file list = %s (%v)", rec.Body.String(), err)
|
|
}
|
|
return rec
|
|
}, into[cabana.Envelope[[]cabana.FileItem]](), nil},
|
|
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/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.partFileID}})
|
|
return e.sendWith(t, http.MethodPost, e.childFilePath("/reorder"), body, "application/json", map[string]string{cabana.ChildSessionKeyHeader: e.childKey})
|
|
}, into[cabana.Envelope[[]cabana.FileItem]](), nil},
|
|
{"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}", 200, "cabana.Envelope-cabana_FileItem", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
|
body, _ := json.Marshal(map[string]any{"title": "Part " + e.stamp})
|
|
return e.sendWith(t, http.MethodPut, e.childFilePath(fmt.Sprintf("/%d", e.partFileID)), body, "application/json", map[string]string{cabana.ChildSessionKeyHeader: e.childKey})
|
|
}, into[cabana.Envelope[cabana.FileItem]](), nil},
|
|
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}/download", 200, "", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
|
return e.sendWith(t, http.MethodGet, e.childFilePath(fmt.Sprintf("/%d/download", e.partFileID)), nil, "", map[string]string{cabana.ChildSessionKeyHeader: e.childKey})
|
|
}, nil, binaryFile("image/png")},
|
|
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}/thumb", 200, "", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
|
return e.sendWith(t, http.MethodGet, e.childFilePath(fmt.Sprintf("/%d/thumb", e.partFileID)), nil, "", map[string]string{cabana.ChildSessionKeyHeader: e.childKey})
|
|
}, nil, binaryFile("image/png")},
|
|
{"DELETE /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}", 200, "cabana.Envelope-cabana_FileMutationResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
|
return e.sendWith(t, http.MethodDelete, e.childFilePath(fmt.Sprintf("/%d", e.partFileID)), nil, "", map[string]string{cabana.ChildSessionKeyHeader: e.childKey})
|
|
}, into[cabana.Envelope[cabana.FileMutationResult]](), nil},
|
|
{"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}", 200, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
|
return e.send(t, http.MethodPut, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records/%d", e.gadgetID, e.partID), map[string]any{"label": "part-" + e.stamp + "-renamed"}, true)
|
|
}, into[cabana.RecordEnvelope](), nil},
|
|
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/delete", 200, "cabana.Envelope-cabana_BulkResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
|
return e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/delete", e.gadgetID), map[string]any{"ids": []uint{e.partID}}, true)
|
|
}, into[cabana.Envelope[cabana.BulkResult]](), 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]](), 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]](), 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]](), 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]](), nil},
|
|
}
|
|
|
|
inventory := map[string]bool{}
|
|
for _, key := range cabana.AdminAPIRoutes() {
|
|
inventory[key] = true
|
|
}
|
|
covered := map[string]bool{}
|
|
for _, tc := range cases {
|
|
if !inventory[tc.key] {
|
|
t.Fatalf("conformance case %s is not an inventoried admin route", tc.key)
|
|
}
|
|
if covered[tc.key] {
|
|
t.Fatalf("duplicate conformance case %s", tc.key)
|
|
}
|
|
covered[tc.key] = true
|
|
}
|
|
for key := range inventory {
|
|
if !covered[key] {
|
|
t.Fatalf("admin route %s has no conformance case", key)
|
|
}
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
ok := t.Run(tc.key, func(t *testing.T) {
|
|
rec := tc.call(t, env)
|
|
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())
|
|
}
|
|
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)
|
|
}
|
|
})
|
|
if !ok {
|
|
t.FailNow()
|
|
}
|
|
}
|
|
}
|
|
|
|
type conformSpecDoc struct {
|
|
Paths map[string]map[string]struct {
|
|
Responses map[string]struct {
|
|
Content map[string]struct {
|
|
Schema struct {
|
|
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/")
|
|
}
|
|
|
|
func conformSpec(t *testing.T) conformSpecDoc {
|
|
t.Helper()
|
|
_, file, _, ok := runtime.Caller(0)
|
|
if !ok {
|
|
t.Fatal("caller")
|
|
}
|
|
raw, err := os.ReadFile(filepath.Join(filepath.Dir(file), "..", "..", "admin", "openapi", "admin.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var doc conformSpecDoc
|
|
if err := json.Unmarshal(raw, &doc); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return doc
|
|
}
|
|
|
|
type conformEnv struct {
|
|
h http.Handler
|
|
db *gorm.DB
|
|
bucket *blob.Bucket
|
|
login string
|
|
token string
|
|
stamp string
|
|
sessionKey string
|
|
photoID uint
|
|
manualID uint
|
|
groupID uint
|
|
memberID uint
|
|
gadgetID uint
|
|
partID uint
|
|
partFileID uint
|
|
childKey string
|
|
}
|
|
|
|
// sendWith sends a raw body with extra headers through the assembled router.
|
|
func (e *conformEnv) sendWith(t *testing.T, method, rel string, body []byte, contentType string, headers map[string]string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := httptest.NewRequest(method, adminAPI(rel), bytes.NewReader(body))
|
|
if contentType != "" {
|
|
req.Header.Set("Content-Type", contentType)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+e.token)
|
|
for k, v := range headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
e.h.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
// upload posts one multipart file_data part to a gadget's file field.
|
|
func (e *conformEnv) upload(t *testing.T, id uint, field, name string, data []byte, key string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
mw := multipart.NewWriter(&buf)
|
|
part, err := mw.CreateFormFile("file_data", name)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := part.Write(data); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := mw.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
headers := map[string]string{}
|
|
if key != "" {
|
|
headers[cabana.SessionKeyHeader] = key
|
|
}
|
|
return e.sendWith(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/files/%s", id, field), buf.Bytes(), mw.FormDataContentType(), headers)
|
|
}
|
|
|
|
// childFilePath is the conformance part's file route under the gadget's
|
|
// parts relation, with rest appended.
|
|
func (e *conformEnv) childFilePath(rest string) string {
|
|
return fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records/%d/files/images%s", e.gadgetID, e.partID, rest)
|
|
}
|
|
|
|
// childUpload posts one multipart file_data part to a part's file field
|
|
// under a gadget's parts relation (child 0 is the part being created).
|
|
func (e *conformEnv) childUpload(t *testing.T, gadget, part uint, field, name string, data []byte, childKey string, extra ...string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
mw := multipart.NewWriter(&buf)
|
|
w, err := mw.CreateFormFile("file_data", name)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := w.Write(data); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := mw.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
headers := map[string]string{cabana.ChildSessionKeyHeader: childKey}
|
|
if len(extra) > 0 {
|
|
headers[cabana.SessionKeyHeader] = extra[0]
|
|
}
|
|
return e.sendWith(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records/%d/files/%s", gadget, part, field), buf.Bytes(), mw.FormDataContentType(), headers)
|
|
}
|
|
|
|
// conformPNG is a small valid PNG.
|
|
func conformPNG(t *testing.T) []byte {
|
|
t.Helper()
|
|
img := image.NewRGBA(image.Rect(0, 0, 4, 3))
|
|
for x := 0; x < 4; x++ {
|
|
img.Set(x, 1, color.RGBA{R: 200, A: 255})
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := png.Encode(&buf, img); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
// newSessionKey is a random form session key as the SPA makes one.
|
|
func newSessionKey(t *testing.T) string {
|
|
t.Helper()
|
|
raw := make([]byte, 32)
|
|
if _, err := rand.Read(raw); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(raw)
|
|
}
|
|
|
|
func (e *conformEnv) send(t *testing.T, method, rel string, body any, auth bool) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var payload []byte
|
|
if body != nil {
|
|
raw, err := json.Marshal(body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
payload = raw
|
|
}
|
|
req := httptest.NewRequest(method, adminAPI(rel), bytes.NewReader(payload))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept-Language", "pl")
|
|
if auth {
|
|
req.Header.Set("Authorization", "Bearer "+e.token)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
e.h.ServeHTTP(rec, req)
|
|
if method == http.MethodPost && rel == "/auth/login" && rec.Code == http.StatusOK {
|
|
e.token = accessToken(t, rec.Body.Bytes())
|
|
}
|
|
return rec
|
|
}
|
|
|
|
// assertPluginAsset fetches a schema asset URL through the assembled router
|
|
// and checks the D-16 headers; an undeclared file under the same directory
|
|
// must fall through to the SPA's 404.
|
|
func (e *conformEnv) assertPluginAsset(t *testing.T, url, contentType string) {
|
|
t.Helper()
|
|
path, version, ok := strings.Cut(url, "?v=")
|
|
if !ok || !strings.HasPrefix(path, cabana.DefaultAdminPrefix+"/assets/acme/conform/") || len(version) != 12 {
|
|
t.Fatalf("asset url %q", url)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
e.h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, url, nil))
|
|
h := rec.Header()
|
|
if rec.Code != http.StatusOK || h.Get("Content-Type") != contentType || h.Get("X-Content-Type-Options") != "nosniff" ||
|
|
h.Get("Cross-Origin-Resource-Policy") != "same-origin" || h.Get("Cache-Control") != "no-cache" ||
|
|
!strings.HasPrefix(h.Get("ETag"), `"`+version) || !strings.Contains(h.Get("Content-Security-Policy"), "script-src 'self'") {
|
|
t.Fatalf("asset %s status=%d headers=%v", url, rec.Code, h)
|
|
}
|
|
miss := httptest.NewRecorder()
|
|
e.h.ServeHTTP(miss, httptest.NewRequest(http.MethodGet, cabana.DefaultAdminPrefix+"/assets/acme/conform/../controllers/gadgets/config_list.yaml", nil))
|
|
if miss.Code == http.StatusOK && strings.Contains(miss.Body.String(), "modelClass") {
|
|
t.Fatalf("plugin YAML leaked through the asset route")
|
|
}
|
|
undeclared := httptest.NewRecorder()
|
|
e.h.ServeHTTP(undeclared, httptest.NewRequest(http.MethodGet, cabana.DefaultAdminPrefix+"/assets/acme/conform/js/other.js", nil))
|
|
if undeclared.Code != http.StatusNotFound {
|
|
t.Fatalf("undeclared asset status=%d", undeclared.Code)
|
|
}
|
|
}
|
|
|
|
func dataID(t *testing.T, raw []byte) uint {
|
|
t.Helper()
|
|
var body struct {
|
|
Data struct {
|
|
ID uint `json:"id"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(raw, &body); err != nil || body.Data.ID == 0 {
|
|
t.Fatalf("no data.id in %s: %v", raw, err)
|
|
}
|
|
return body.Data.ID
|
|
}
|
|
|
|
func newConformEnv(t *testing.T) *conformEnv {
|
|
t.Helper()
|
|
gdb := adminGorm(t)
|
|
models := []any{&conformGadget{}, &conformGroup{}, &conformMember{}, &conformGadgetMember{}, &conformPart{}, &conformSettings{}}
|
|
if err := gdb.Migrator().DropTable(models...); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := gdb.AutoMigrate(models...); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Gadget ids restart with the recreated table: drop the files and
|
|
// bindings an earlier run left for them.
|
|
if err := gdb.Exec(`DELETE FROM system_files WHERE attachment_type IN ('acme.conform.gadget', 'acme.conform.part') OR attachment_id IS NULL OR attachment_id = ''`).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := gdb.Exec(`DELETE FROM deferred_bindings WHERE master_type IN ('acme.conform.gadget', 'acme.conform.part')`).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stamp := fmt.Sprintf("c%d", time.Now().UnixNano())
|
|
group := conformGroup{Title: "Group " + stamp}
|
|
member := conformMember{Email: "member-" + stamp + "@example.test"}
|
|
if err := gdb.Create(&group).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := gdb.Create(&member).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
login := "conform-" + stamp
|
|
insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false)
|
|
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-conform\nlocale: en\nfallback_locale: en\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for key, value := range map[string]any{"http.body_limits.default_bytes": 1048576, "http.body_limits.upload_bytes": 1048576} {
|
|
if err := cfg.Set(key, value); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
app := backpack.New(cfg)
|
|
if err := lagoon.Publish(app, adminSQL, gdb); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bucket := memblob.OpenBucket(nil)
|
|
t.Cleanup(func() { _ = bucket.Close() })
|
|
if err := attach.Publish(app, bucket); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
plugins := []party.Plugin{conformPlugin{stamp: stamp}}
|
|
if err := phrasebook.Activate(app, plugins); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h, err := surf.Assemble(app, plugins)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return &conformEnv{h: h, db: gdb, bucket: bucket, login: login, stamp: stamp, sessionKey: newSessionKey(t), childKey: newSessionKey(t), groupID: group.ID, memberID: member.ID}
|
|
}
|
|
|
|
type conformGadget struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
Name string `gorm:"column:name"`
|
|
Active bool `gorm:"column:active"`
|
|
GroupID *uint `gorm:"column:group_id"`
|
|
// ReleasedOn and StartsAt are the date and datetime datepicker columns.
|
|
ReleasedOn lagoon.Date `gorm:"column:released_on;type:date"`
|
|
StartsAt *time.Time `gorm:"column:starts_at"`
|
|
Members []conformMember `gorm:"-"`
|
|
Parts []conformPart `gorm:"-"`
|
|
CreatedAt time.Time `gorm:"column:created_at"`
|
|
UpdatedAt time.Time `gorm:"column:updated_at"`
|
|
}
|
|
|
|
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}, {Name: "manual"}}
|
|
}
|
|
func (conformGadget) Fillable() []string {
|
|
return []string{"name", "active", "released_on", "starts_at"}
|
|
}
|
|
func (conformGadget) Rules() map[string]string { return map[string]string{"name": "required"} }
|
|
func (conformGadget) FilterScopes() []string { return []string{"filterByGroup"} }
|
|
func (conformGadget) FilterScope(_ string, db *gorm.DB, value any) *gorm.DB {
|
|
return db.Where("group_id = ?", value)
|
|
}
|
|
func (conformGadget) FilterOptions(string) []pact.Option {
|
|
return []pact.Option{{Value: "1", Label: "backend::lang.form.none"}, {Value: "2", Label: "Second"}}
|
|
}
|
|
|
|
type conformGroup struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
Title string `gorm:"column:title"`
|
|
}
|
|
|
|
func (conformGroup) TableName() string { return "cabana_conform_groups" }
|
|
|
|
type conformMember struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
Email string `gorm:"column:email"`
|
|
}
|
|
|
|
func (conformMember) TableName() string { return "cabana_conform_members" }
|
|
|
|
type conformGadgetMember struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
GadgetID uint `gorm:"column:gadget_id"`
|
|
MemberID uint `gorm:"column:member_id"`
|
|
Note string `gorm:"column:note"`
|
|
// Stamp is a hook column RelationBeforeLink writes.
|
|
Stamp string `gorm:"column:stamp"`
|
|
}
|
|
|
|
func (conformGadgetMember) TableName() string { return "cabana_conform_gadget_members" }
|
|
|
|
// conformPart is the hasMany child of a gadget; its nullable gadget_id makes
|
|
// the parts relation deferrable.
|
|
type conformPart struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
GadgetID *uint `gorm:"column:gadget_id"`
|
|
Label string `gorm:"column:label"`
|
|
CreatedAt time.Time `gorm:"column:created_at"`
|
|
UpdatedAt time.Time `gorm:"column:updated_at"`
|
|
}
|
|
|
|
func (conformPart) TableName() string { return "cabana_conform_parts" }
|
|
func (conformPart) MorphName() string { return "acme.conform.part" }
|
|
func (conformPart) Fillable() []string { return []string{"label"} }
|
|
func (conformPart) Rules() map[string]string { return map[string]string{"label": "required"} }
|
|
|
|
// AttachRelations declares the part form's protected attachMany images.
|
|
func (conformPart) AttachRelations() []attach.Relation {
|
|
return []attach.Relation{{Name: "images", Many: true}}
|
|
}
|
|
|
|
type conformSettings struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
Enabled bool `gorm:"column:enabled"`
|
|
}
|
|
|
|
func (conformSettings) TableName() string { return "cabana_conform_settings" }
|
|
func (conformSettings) Fillable() []string { return []string{"enabled"} }
|
|
func (conformSettings) Rules() map[string]string { return map[string]string{} }
|
|
|
|
type conformPlugin struct{ stamp string }
|
|
|
|
func (conformPlugin) ID() string { return "acme.conform" }
|
|
func (conformPlugin) Requires() []string { return nil }
|
|
func (conformPlugin) Register(*backpack.App) error { return nil }
|
|
func (conformPlugin) Boot(*backpack.App) error { return nil }
|
|
func (p conformPlugin) AdminControllers() []pact.AdminController {
|
|
return []pact.AdminController{conformController{stamp: p.stamp}}
|
|
}
|
|
|
|
// Models lists the models deferred:purge may delete; the parts relation
|
|
// creates parts under deferral, so cabana's boot requires conformPart here.
|
|
func (conformPlugin) Models() []any { return []any{&conformGadget{}, &conformPart{}} }
|
|
|
|
func (conformPlugin) Permissions() []pact.Permission {
|
|
return []pact.Permission{{Code: "acme.conform.access", Roles: []string{"developer"}}}
|
|
}
|
|
func (conformPlugin) Navigation() []pact.NavigationItem {
|
|
return []pact.NavigationItem{{Code: "conform", Label: "Conform", Icon: "box", Order: 10, Controller: "acme.conform.gadgets",
|
|
Permissions: []string{"acme.conform.access"},
|
|
SideMenu: []pact.NavigationItem{{Code: "gadgets", Label: "Gadgets", Icon: "box", Controller: "acme.conform.gadgets", Permissions: []string{"acme.conform.access"}}}}}
|
|
}
|
|
func (conformPlugin) Settings() []pact.SettingsItem {
|
|
return []pact.SettingsItem{{Code: "conform", Label: "Conform", Description: "Conform settings", Category: "Acme", Icon: "settings",
|
|
Model: "ConformSettings", Order: 10, Permissions: []string{"acme.conform.access"},
|
|
Form: "models/settings/fields.yaml", NewModel: func() any { return &conformSettings{} }}}
|
|
}
|
|
func (conformPlugin) AdminFS() fs.FS { return conformFS() }
|
|
|
|
type conformController struct{ stamp string }
|
|
|
|
func (conformController) ID() string { return "acme.conform.gadgets" }
|
|
func (conformController) ModelName() string { return "Gadget" }
|
|
func (conformController) ConfigDir() string { return "controllers/gadgets" }
|
|
func (conformController) RequiredPermissions() []string { return []string{"acme.conform.access"} }
|
|
func (conformController) NewRecord() any { return &conformGadget{} }
|
|
func (conformController) AdminRelationContracts() []cabana.RelationContract {
|
|
return []cabana.RelationContract{{
|
|
Name: "members", NewRelated: func() any { return &conformMember{} }, NewPivot: func() any { return &conformGadgetMember{} },
|
|
ParentForeignKey: "gadget_id", RelatedForeignKey: "member_id", Columns: map[string]string{"email": "email"},
|
|
HookPivotColumns: []string{"stamp"},
|
|
// A gadget named exclude-<id>-... may not link member <id>, so a
|
|
// link the create form deferred can turn ineligible at save.
|
|
ExcludedRelatedIDs: func(parent any) ([]uint, error) {
|
|
var id uint
|
|
if g, ok := parent.(*conformGadget); ok && g != nil {
|
|
if _, err := fmt.Sscanf(g.Name, "exclude-%d-", &id); err == nil {
|
|
return []uint{id}, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
},
|
|
}, {
|
|
Name: "parts", Kind: cabana.RelationHasMany, NewRelated: func() any { return &conformPart{} },
|
|
ForeignKey: "gadget_id", Columns: map[string]string{"label": "label"},
|
|
}}
|
|
}
|
|
|
|
// RelationBeforeLink stamps the server-owned pivot column of a members link.
|
|
func (conformController) RelationBeforeLink(_ context.Context, relation string, _, _ any, pivot map[string]any) error {
|
|
if relation == "members" {
|
|
pivot["stamp"] = "linked"
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FormExtendQuery hides gadgets whose name starts with hidden-, so the
|
|
// smoke tests can check that a hidden parent scopes its children away.
|
|
func (conformController) FormExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB {
|
|
return db.Where("name NOT LIKE ?", "hidden-%")
|
|
}
|
|
|
|
func (conformController) AdminFieldRelations() []cabana.FieldRelationContract {
|
|
return []cabana.FieldRelationContract{{Field: "group", Kind: "belongsTo", NewRelated: func() any { return &conformGroup{} }, ForeignKey: "group_id"}}
|
|
}
|
|
|
|
func (c conformController) AdminActions() []pact.AdminAction {
|
|
return []pact.AdminAction{{
|
|
Name: "lookup", Label: "Look up", Permissions: []string{"acme.conform.access"},
|
|
Run: func(context.Context, pact.AdminActionInput) (pact.AdminActionResult, error) {
|
|
return pact.AdminActionResult{Message: "Looked up", Fill: map[string]any{"name": "lookup-" + c.stamp, "active": true}}, nil
|
|
},
|
|
}, {
|
|
Name: "recount", Label: "Recount", Permissions: []string{"acme.conform.access"},
|
|
Run: func(context.Context, pact.AdminActionInput) (pact.AdminActionResult, error) {
|
|
return pact.AdminActionResult{Message: "Recounted"}, nil
|
|
},
|
|
}}
|
|
}
|
|
func (conformController) AdminJS() []string { return []string{"assets/js/lookup.js"} }
|
|
|
|
// PartialData supplies curated view models, never the gadget model itself.
|
|
func (conformController) PartialData(_ context.Context, name string, record any) (any, error) {
|
|
switch name {
|
|
case "stats":
|
|
return struct{ Total int }{Total: 1}, nil
|
|
case "summary":
|
|
view := struct{ Name string }{}
|
|
if gadget, ok := record.(*conformGadget); ok && gadget != nil {
|
|
view.Name = gadget.Name
|
|
}
|
|
return view, nil
|
|
default:
|
|
return nil, fmt.Errorf("unknown partial %s", name)
|
|
}
|
|
}
|
|
func (conformController) AdminCSS() []string { return []string{"assets/css/gadgets.css"} }
|
|
|
|
func conformFS() fs.FS {
|
|
file := func(s string) *fstest.MapFile { return &fstest.MapFile{Data: []byte(s)} }
|
|
return fstest.MapFS{
|
|
"controllers/gadgets/config_list.yaml": file(`list: ~/plugins/acme/conform/models/gadget/columns.yaml
|
|
modelClass: Gadget
|
|
title: Gadgets
|
|
recordUrl: acme/conform/gadgets/update/:id
|
|
recordsPerPage: 20
|
|
showCheckboxes: true
|
|
filter: config_filter.yaml
|
|
headerPartial: stats
|
|
toolbar:
|
|
buttons: [create, delete, recount]
|
|
search:
|
|
prompt: backend::lang.list.search_prompt
|
|
`),
|
|
// A plain custom element: a light-DOM button that asks the admin SPA
|
|
// to run the field's action. It makes no network call and never
|
|
// touches cookies; the SPA owns HTTP (D-05).
|
|
"assets/js/lookup.js": file(`class AcmeConformLookup extends HTMLElement {
|
|
connectedCallback() {
|
|
if (this.firstChild) return
|
|
const button = document.createElement('button')
|
|
button.type = 'button'
|
|
button.textContent = this.getAttribute('label') || 'Lookup'
|
|
button.addEventListener('click', () => {
|
|
this.dispatchEvent(new CustomEvent('summer-action', { bubbles: true, composed: true }))
|
|
})
|
|
this.append(button)
|
|
}
|
|
}
|
|
if (!customElements.get('acme-conform-lookup')) {
|
|
customElements.define('acme-conform-lookup', AcmeConformLookup)
|
|
}
|
|
`),
|
|
"assets/css/gadgets.css": file(`acme-conform-lookup button { font: inherit; }
|
|
`),
|
|
"controllers/gadgets/_stats.htm": file(`<dl class="summer-stats">
|
|
<div class="summer-stat"><dt class="summer-stat__label">{{ trans "backend::lang.list.search" }}</dt><dd class="summer-stat__value">{{ .Data.Total }}</dd></div>
|
|
</dl>
|
|
`),
|
|
"controllers/gadgets/_summary.htm": file(`<p>{{ .Data.Name }}</p>
|
|
`),
|
|
"controllers/gadgets/config_filter.yaml": file(`scopes:
|
|
grouped:
|
|
label: Group
|
|
modelClass: Group
|
|
nameFrom: title
|
|
scope: filterByGroup
|
|
active:
|
|
label: Active
|
|
type: switch
|
|
column: active
|
|
`),
|
|
"controllers/gadgets/config_form.yaml": file(`name: New gadget
|
|
form: ~/plugins/acme/conform/models/gadget/fields.yaml
|
|
modelClass: Gadget
|
|
defaultRedirect: acme/conform/gadgets
|
|
create:
|
|
redirect: acme/conform/gadgets/update/:id
|
|
redirectClose: acme/conform/gadgets
|
|
update:
|
|
redirect: acme/conform/gadgets
|
|
redirectClose: acme/conform/gadgets
|
|
`),
|
|
"controllers/gadgets/config_relation.yaml": file(`members:
|
|
label: Members
|
|
view:
|
|
list:
|
|
columns:
|
|
email:
|
|
label: Email
|
|
toolbarButtons: link|unlink
|
|
showSearch: true
|
|
manage:
|
|
list:
|
|
columns:
|
|
email:
|
|
label: Email
|
|
showSearch: true
|
|
pivot:
|
|
form: models/member/pivot_fields.yaml
|
|
parts:
|
|
label: Parts
|
|
view:
|
|
list:
|
|
columns:
|
|
label:
|
|
label: Label
|
|
toolbarButtons: create|update|delete|link|unlink
|
|
manage:
|
|
form: $/acme/conform/models/part/fields.yaml
|
|
list:
|
|
columns:
|
|
label:
|
|
label: Label
|
|
`),
|
|
"models/gadget/columns.yaml": file(`columns:
|
|
name:
|
|
label: Name
|
|
searchable: true
|
|
active:
|
|
label: Active
|
|
type: switch
|
|
released_on:
|
|
label: Released on
|
|
type: date
|
|
`),
|
|
"models/gadget/fields.yaml": file(`fields:
|
|
name:
|
|
label: Name
|
|
type: text
|
|
required: true
|
|
span: left
|
|
active:
|
|
label: Active
|
|
type: switch
|
|
default: false
|
|
group:
|
|
label: Group
|
|
type: relation
|
|
nameFrom: title
|
|
emptyOption: backend::lang.form.none
|
|
members:
|
|
type: relation-manager
|
|
relation: members
|
|
parts:
|
|
type: relation-manager
|
|
relation: parts
|
|
lookup:
|
|
label: Lookup
|
|
type: widget
|
|
widget: acme-conform-lookup
|
|
action: lookup
|
|
fill: [name]
|
|
summary:
|
|
label: Summary
|
|
type: partial
|
|
path: summary
|
|
photos:
|
|
label: Photos
|
|
type: fileupload
|
|
mode: image
|
|
maxFiles: 5
|
|
maxFilesize: 0.5
|
|
thumbOptions:
|
|
mode: crop
|
|
released_on:
|
|
label: Released on
|
|
type: datepicker
|
|
mode: date
|
|
minDate: 2000-01-01
|
|
firstDay: 1
|
|
starts_at:
|
|
label: Starts at
|
|
type: datepicker
|
|
format: d.m.Y H:i
|
|
yearRange: [2000, 2040]
|
|
manual:
|
|
label: Manual
|
|
type: fileupload
|
|
fileTypes: [pdf, png, svg, txt]
|
|
useCaption: true
|
|
context: update
|
|
`),
|
|
"models/member/pivot_fields.yaml": file(`fields:
|
|
pivot[note]:
|
|
label: Note
|
|
type: text
|
|
`),
|
|
"models/part/fields.yaml": file(`fields:
|
|
label:
|
|
label: Label
|
|
type: text
|
|
required: true
|
|
images:
|
|
label: Images
|
|
type: fileupload
|
|
mode: image
|
|
useCaption: true
|
|
`),
|
|
"models/settings/fields.yaml": file(`fields:
|
|
enabled:
|
|
label: Enabled
|
|
type: switch
|
|
default: 0
|
|
`),
|
|
}
|
|
}
|