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/backpack" "git.golem15.com/golem15/summercms/cabana" "git.golem15.com/golem15/summercms/compass" "git.golem15.com/golem15/summercms/lagoon" "git.golem15.com/golem15/summercms/pact" "git.golem15.com/golem15/summercms/party" "git.golem15.com/golem15/summercms/phrasebook" "git.golem15.com/golem15/summercms/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 `), } }