Files
summercms/modules/cabana/openapi_conformance_test.go
Jakub Zych 5e50b166ef refactor(10.2-01): nest framework packages under modules
- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
2026-09-28 02:21:02 +02:00

495 lines
21 KiB
Go

package cabana_test
import (
"bytes"
"encoding/json"
"fmt"
"io/fs"
"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/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/surf"
"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
}
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]]()},
{"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]]()},
{"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]]()},
{"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]]()},
{"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]]()},
{"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]]()},
{"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]]()},
{"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]]()},
{"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]]()},
{"GET /{vendor}/{plugin}/{controller}/schema/list", 200, "cabana.Envelope-cabana_ListSchema", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/acme/conform/gadgets/schema/list", nil, true)
}, into[cabana.Envelope[cabana.ListSchema]]()},
{"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]]()},
{"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]]()},
{"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]]()},
{"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]]()},
{"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]()},
{"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]]()},
{"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]()},
{"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]()},
{"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]]()},
{"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]]()},
{"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]]()},
{"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]]()},
{"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]]()},
{"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]]()},
{"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]]()},
}
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())
}
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)
}
})
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"`
} `json:"schema"`
} `json:"content"`
} `json:"responses"`
} `json:"paths"`
}
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
login string
token string
stamp string
groupID uint
memberID uint
gadgetID uint
}
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
}
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{}, &conformSettings{}}
if err := gdb.Migrator().DropTable(models...); err != nil {
t.Fatal(err)
}
if err := gdb.AutoMigrate(models...); 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)
}
plugins := []party.Plugin{conformPlugin{}}
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, login: login, stamp: stamp, 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"`
Members []conformMember `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) Fillable() []string { return []string{"name", "active"} }
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"`
}
func (conformGadgetMember) TableName() string { return "cabana_conform_gadget_members" }
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{}
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 (conformPlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{conformController{}}
}
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{}
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"},
}}
}
func (conformController) AdminFieldRelations() []cabana.FieldRelationContract {
return []cabana.FieldRelationContract{{Field: "group", Kind: "belongsTo", NewRelated: func() any { return &conformGroup{} }, ForeignKey: "group_id"}}
}
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
toolbar:
buttons: [create, delete]
search:
prompt: backend::lang.list.search_prompt
`),
"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
`),
"models/gadget/columns.yaml": file(`columns:
name:
label: Name
searchable: true
active:
label: Active
type: switch
`),
"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
context: [update]
`),
"models/settings/fields.yaml": file(`fields:
enabled:
label: Enabled
type: switch
default: 0
`),
}
}