package cabana_test import ( "context" "encoding/json" "errors" "fmt" "io/fs" "math" "net/http" "net/http/httptest" "os" "path/filepath" "reflect" "strings" "sync" "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" ) // actDir is the acme fixture plugin tree shared with the internal Phase 10.1 // schema, sanitizer and asset tests. const actDir = "testdata/extension" type actGadget struct { ID uint `gorm:"column:id;primaryKey"` Name string `gorm:"column:name"` Active bool `gorm:"column:active"` GroupID *uint `gorm:"column:group_id"` // Tenant is the controller's form scope; it is not a form field. Tenant string `gorm:"column:tenant"` } func (actGadget) TableName() string { return "cabana_ext_gadgets" } func (actGadget) Fillable() []string { return []string{"name", "active"} } func (actGadget) Rules() map[string]string { return map[string]string{"name": "required"} } type actGroup struct { ID uint `gorm:"column:id;primaryKey"` Title string `gorm:"column:title"` } func (actGroup) TableName() string { return "cabana_ext_groups" } // actTags is a string kind that encodes as a JSON array. type actTags string func (t actTags) MarshalJSON() ([]byte, error) { return json.Marshal(strings.Split(string(t), ",")) } // actSpy records the inputs the registered actions receive. type actSpy struct { mu sync.Mutex calls []pact.AdminActionInput } func (s *actSpy) record(in pact.AdminActionInput) { s.mu.Lock() defer s.mu.Unlock() s.calls = append(s.calls, in) } func (s *actSpy) take() []pact.AdminActionInput { s.mu.Lock() defer s.mu.Unlock() out := s.calls s.calls = nil return out } type actPlugin struct{ spy *actSpy } func (actPlugin) ID() string { return "acme.demo" } func (actPlugin) Requires() []string { return nil } func (actPlugin) Register(*backpack.App) error { return nil } func (actPlugin) Boot(*backpack.App) error { return nil } func (p actPlugin) AdminControllers() []pact.AdminController { return []pact.AdminController{actController{spy: p.spy}} } func (actPlugin) Permissions() []pact.Permission { return []pact.Permission{{Code: "acme.demo.access", Roles: []string{"developer"}}, {Code: "acme.demo.run", Roles: []string{"developer"}}} } func (actPlugin) AdminFS() fs.FS { return os.DirFS(actDir) } // LangFS serves only the fixture's lang/ tree. func (actPlugin) LangFS() fs.FS { out := fstest.MapFS{} for _, name := range []string{"lang/en/lang.yaml", "lang/pl/lang.yaml"} { data, err := os.ReadFile(filepath.Join(actDir, name)) if err != nil { panic(err) } out[name] = &fstest.MapFile{Data: data} } return out } type actController struct{ spy *actSpy } func (actController) ID() string { return "acme.demo.gadgets" } func (actController) ModelName() string { return "Gadget" } func (actController) ConfigDir() string { return "controllers/gadgets" } func (actController) RequiredPermissions() []string { return []string{"acme.demo.access"} } func (actController) NewRecord() any { return &actGadget{} } func (actController) AdminJS() []string { return []string{"assets/js/lookup.js"} } func (actController) AdminCSS() []string { return []string{"assets/css/gadgets.css"} } func (actController) AdminFieldRelations() []cabana.FieldRelationContract { return []cabana.FieldRelationContract{{Field: "group", Kind: "belongsTo", NewRelated: func() any { return &actGroup{} }, ForeignKey: "group_id"}} } // ListExtendQuery and FormExtendQuery scope every lookup to the acme tenant, // so a record of another tenant is out of scope. func (actController) ListExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB { return db.Where("tenant = ?", "acme") } func (actController) FormExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB { return db.Where("tenant = ?", "acme") } // AdminActions: lookup answers by the name value it receives (invalid, boom, // nested or a normal fill); recount is the declared toolbar action; hidden is // registered but not in toolbar.buttons. func (c actController) AdminActions() []pact.AdminAction { return []pact.AdminAction{{ Name: "lookup", Label: "acme.demo::lang.gadgets.lookup", Permissions: []string{"acme.demo.run"}, Run: func(_ context.Context, in pact.AdminActionInput) (pact.AdminActionResult, error) { c.spy.record(in) switch in.Values["name"] { case "invalid": return pact.AdminActionResult{}, &cabana.ValidationError{Details: map[string]any{"name": []string{"Name is taken."}}} case "boom": return pact.AdminActionResult{}, errors.New("upstream said hunter2") case "nested": return pact.AdminActionResult{Fill: map[string]any{"name": []string{"a"}, "active": false}}, nil case "encoded": // Scalar kinds that do not encode as JSON scalars (WR-04). return pact.AdminActionResult{Fill: map[string]any{"name": actTags("a,b"), "active": math.NaN()}}, nil } return pact.AdminActionResult{ Message: "acme.demo::lang.gadgets.looked_up", Fill: map[string]any{"name": "looked-up", "active": true, "tenant": "other", "group": 1, "id": 99}, }, nil }, }, { Name: "recount", Label: "acme.demo::lang.gadgets.recount", Permissions: []string{"acme.demo.run"}, Run: func(_ context.Context, in pact.AdminActionInput) (pact.AdminActionResult, error) { c.spy.record(in) return pact.AdminActionResult{Message: "acme.demo::lang.gadgets.recounted", Fill: map[string]any{"name": "ignored"}}, nil }, }, { Name: "hidden", Label: "Hidden", Run: func(_ context.Context, in pact.AdminActionInput) (pact.AdminActionResult, error) { c.spy.record(in) return pact.AdminActionResult{}, nil }, }} } func (actController) PartialData(_ context.Context, name string, record any) (any, error) { switch name { case "stats": return struct { Items []struct { Label string Count int } }{Items: []struct { Label string Count int }{{Label: "acme.demo::lang.gadgets.total", Count: 2}}}, nil case "summary": view := struct{ Name string }{} if gadget, ok := record.(*actGadget); ok && gadget != nil { if gadget.Name == "explode" { return nil, errors.New("view model failed") } view.Name = gadget.Name } return view, nil } return nil, fmt.Errorf("unknown partial %s", name) } type actEnv struct { h http.Handler spy *actSpy token string cookie *http.Cookie limited string } // actRequest is one admin API call. auth is "bearer" (developer token), // "limited" (a token without acme.demo.run), "cookie" (cookie plus // X-Requested-With) or "cookie-only" (cookie without the CSRF header). func (e *actEnv) call(t *testing.T, method, rel, body, auth string) *httptest.ResponseRecorder { t.Helper() var reader *strings.Reader if body != "" { reader = strings.NewReader(body) } else { reader = strings.NewReader("") } req := httptest.NewRequest(method, adminAPI(rel), reader) if body != "" { req.Header.Set("Content-Type", "application/json") } req.Header.Set("Accept-Language", "en") switch auth { case "bearer": req.Header.Set("Authorization", "Bearer "+e.token) case "limited": req.Header.Set("Authorization", "Bearer "+e.limited) case "cookie": req.AddCookie(e.cookie) req.Header.Set("X-Requested-With", "XMLHttpRequest") case "cookie-only": req.AddCookie(e.cookie) default: t.Fatalf("unknown auth mode %s", auth) } rec := httptest.NewRecorder() e.h.ServeHTTP(rec, req) return rec } func (e *actEnv) expect(t *testing.T, status int, method, rel, body, auth string) *httptest.ResponseRecorder { t.Helper() rec := e.call(t, method, rel, body, auth) if rec.Code != status { t.Fatalf("%s %s %s status=%d want %d body=%s", auth, method, rel, rec.Code, status, rec.Body.String()) } return rec } func actResult(t *testing.T, rec *httptest.ResponseRecorder) cabana.AdminActionResult { t.Helper() var body cabana.Envelope[cabana.AdminActionResult] if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("action body %s: %v", rec.Body.String(), err) } return body.Data } func newActEnv(t *testing.T) (*actEnv, *gorm.DB) { t.Helper() gdb := adminGorm(t) models := []any{&actGadget{}, &actGroup{}} if err := gdb.Migrator().DropTable(models...); err != nil { t.Fatal(err) } if err := gdb.AutoMigrate(models...); err != nil { t.Fatal(err) } stamp := fmt.Sprintf("a%d", time.Now().UnixNano()) login := "ext-" + stamp insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false) var roleID uint if err := gdb.Raw(`INSERT INTO backend_user_roles (name, code, permissions, is_system, created_at, updated_at) VALUES (?, ?, ?, FALSE, NOW(), NOW()) RETURNING id`, "Ext limited "+stamp, "ext-limited-"+stamp, `{"acme.demo.access":1}`).Scan(&roleID).Error; err != nil || roleID == 0 { t.Fatalf("limited role: id=%d err=%v", roleID, err) } limitedLogin := "ext-limited-" + stamp limited := insertAdmin(t, gdb, limitedLogin, limitedLogin+"@example.test", adminTestPassword, true, false) if err := gdb.Exec(`UPDATE backend_users SET role_id = ? WHERE id = ?`, roleID, limited.ID).Error; err != nil { t.Fatal(err) } dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-extension\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) } spy := &actSpy{} plugins := []party.Plugin{actPlugin{spy: spy}} if err := phrasebook.Activate(app, plugins); err != nil { t.Fatal(err) } h, err := surf.Assemble(app, plugins) if err != nil { t.Fatal(err) } env := &actEnv{h: h, spy: spy} rec := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": login, "password": adminTestPassword}) if rec.Code != http.StatusOK { t.Fatalf("login status=%d body=%s", rec.Code, rec.Body.String()) } env.token = accessToken(t, rec.Body.Bytes()) // The admin cookie carries the same JWT the SPA's cookie login sets. env.cookie = &http.Cookie{Name: cabana.AdminCookieName, Value: env.token} rec = postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": limitedLogin, "password": adminTestPassword}) if rec.Code != http.StatusOK { t.Fatalf("limited login status=%d body=%s", rec.Code, rec.Body.String()) } env.limited = accessToken(t, rec.Body.Bytes()) return env, gdb } func actInsert(t *testing.T, gdb *gorm.DB, name, tenant string) uint { t.Helper() row := actGadget{Name: name, Tenant: tenant} if err := gdb.Create(&row).Error; err != nil { t.Fatal(err) } return row.ID } // TestPhase101Actions drives the widget, toolbar and partial routes through // the assembled router on PostgreSQL (D-05, D-07, D-09, D-12; T-10.1-04 to // T-10.1-07): record scoping, the fill filter in both directions, the strict // body, the action permission, error mapping and the CSRF header. func TestPhase101Actions(t *testing.T) { env, gdb := newActEnv(t) mine := actInsert(t, gdb, "mine", "acme") foreign := actInsert(t, gdb, "foreign", "other") explode := actInsert(t, gdb, "explode", "acme") const widget = "/acme/demo/gadgets/widgets/lookup" const toolbar = "/acme/demo/gadgets/toolbar/recount" t.Run("widget with an in-scope record", func(t *testing.T) { rec := env.expect(t, http.StatusOK, http.MethodPost, widget, fmt.Sprintf(`{"record_id":%d,"values":{"name":"typed","active":true,"tenant":"other","group":3,"id":7}}`, mine), "bearer") result := actResult(t, rec) if !reflect.DeepEqual(result.Fill, map[string]any{"name": "looked-up", "active": true}) || result.Message != "Name and Active were filled in." { t.Fatalf("result = %+v", result) } calls := env.spy.take() if len(calls) != 1 { t.Fatalf("calls = %+v", calls) } in := calls[0] record, ok := in.Record.(*actGadget) if in.Field != "lookup" || in.RecordID == nil || *in.RecordID != uint64(mine) || !ok || record.ID != mine || record.Name != "mine" { t.Fatalf("input = %+v record=%+v", in, in.Record) } if !reflect.DeepEqual(in.Values, map[string]any{"name": "typed", "active": true}) { t.Fatalf("values = %#v", in.Values) } }) t.Run("non-scalar values and fill are dropped", func(t *testing.T) { rec := env.expect(t, http.StatusOK, http.MethodPost, widget, `{"values":{"name":"nested","active":{"x":1}}}`, "bearer") if result := actResult(t, rec); !reflect.DeepEqual(result.Fill, map[string]any{"active": false}) { t.Fatalf("fill = %#v", result.Fill) } calls := env.spy.take() if len(calls) != 1 || !reflect.DeepEqual(calls[0].Values, map[string]any{"name": "nested"}) { t.Fatalf("calls = %+v", calls) } }) t.Run("fill is judged by its JSON encoding, not its kind", func(t *testing.T) { rec := env.expect(t, http.StatusOK, http.MethodPost, widget, `{"values":{"name":"encoded"}}`, "bearer") if result := actResult(t, rec); !reflect.DeepEqual(result.Fill, map[string]any{}) { t.Fatalf("fill = %#v", result.Fill) } env.spy.take() }) t.Run("widget on the create form gets no record", func(t *testing.T) { env.expect(t, http.StatusOK, http.MethodPost, widget, `{}`, "bearer") calls := env.spy.take() if len(calls) != 1 || calls[0].RecordID != nil || calls[0].Record != nil || len(calls[0].Values) != 0 || calls[0].Values == nil { t.Fatalf("calls = %+v", calls) } }) t.Run("out-of-scope and missing records are 404", func(t *testing.T) { for _, id := range []uint{foreign, 999999} { rec := env.expect(t, http.StatusNotFound, http.MethodPost, widget, fmt.Sprintf(`{"record_id":%d}`, id), "bearer") if strings.Contains(rec.Body.String(), "foreign") { t.Fatalf("404 leaked the record: %s", rec.Body.String()) } } if calls := env.spy.take(); len(calls) != 0 { t.Fatalf("action ran for an out-of-scope record: %+v", calls) } }) t.Run("strict body", func(t *testing.T) { for _, body := range []string{ `{"record_id":1,"extra":true}`, `{"values":{}} {}`, `{"record_id":-1}`, `{"record_id":"1"}`, `{"values":[1]}`, `{`, `[]`, ``, } { rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, widget, body, "bearer") actErrorCode(t, rec.Body.Bytes(), "validation_failed") } if calls := env.spy.take(); len(calls) != 0 { t.Fatalf("action ran for a malformed body: %+v", calls) } }) t.Run("only widget fields are routes", func(t *testing.T) { for _, field := range []string{"name", "summary", "group", "missing"} { env.expect(t, http.StatusNotFound, http.MethodPost, "/acme/demo/gadgets/widgets/"+field, `{}`, "bearer") } env.expect(t, http.StatusNotFound, http.MethodPost, "/acme/demo/nope/widgets/lookup", `{}`, "bearer") }) t.Run("action permission on top of the controller's", func(t *testing.T) { env.expect(t, http.StatusForbidden, http.MethodPost, widget, `{}`, "limited") env.expect(t, http.StatusForbidden, http.MethodPost, toolbar, `{}`, "limited") // The limited admin may open the controller, and its list schema // offers no toolbar action it cannot run. rec := env.expect(t, http.StatusOK, http.MethodGet, "/acme/demo/gadgets/schema/list", "", "limited") var list cabana.Envelope[cabana.ListSchema] if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil || len(list.Data.ToolbarActions) != 0 { t.Fatalf("limited toolbarActions = %+v err=%v", list.Data.ToolbarActions, err) } env.expect(t, http.StatusOK, http.MethodGet, "/acme/demo/gadgets/partials/stats", "", "limited") // WR-05: the form schema offers no widget whose action the admin // cannot run, and keeps every other field; an admin who may run it // still gets the widget. widgets := func(token string) (widgets, others []string) { t.Helper() rec := env.expect(t, http.StatusOK, http.MethodGet, "/acme/demo/gadgets/schema/form", "", token) var form cabana.Envelope[cabana.FormView] if err := json.Unmarshal(rec.Body.Bytes(), &form); err != nil { t.Fatal(err) } for _, field := range form.Data.Fields { if field.Type == "widget" { widgets = append(widgets, field.Name+":"+field.Action) } else { others = append(others, field.Name) } } return widgets, others } limitedWidgets, limitedOthers := widgets("limited") fullWidgets, fullOthers := widgets("bearer") if len(limitedWidgets) != 0 || strings.Contains(strings.Join(limitedOthers, ","), "lookup") { t.Fatalf("limited form widgets = %v", limitedWidgets) } if !reflect.DeepEqual(fullWidgets, []string{"lookup:lookup"}) || !reflect.DeepEqual(limitedOthers, fullOthers) { t.Fatalf("full widgets = %v, others limited %v full %v", fullWidgets, limitedOthers, fullOthers) } if calls := env.spy.take(); len(calls) != 0 { t.Fatalf("action ran for a denied admin: %+v", calls) } }) t.Run("action errors", func(t *testing.T) { rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, widget, `{"values":{"name":"invalid"}}`, "bearer") if !strings.Contains(rec.Body.String(), "Name is taken.") { t.Fatalf("validation details missing: %s", rec.Body.String()) } rec = env.expect(t, http.StatusInternalServerError, http.MethodPost, widget, `{"values":{"name":"boom"}}`, "bearer") actErrorCode(t, rec.Body.Bytes(), "error") if strings.Contains(rec.Body.String(), "hunter2") { t.Fatalf("500 body leaked the error text: %s", rec.Body.String()) } env.spy.take() }) t.Run("cookie POSTs need X-Requested-With", func(t *testing.T) { for _, path := range []string{widget, toolbar} { rec := env.expect(t, http.StatusForbidden, http.MethodPost, path, `{}`, "cookie-only") actErrorCode(t, rec.Body.Bytes(), "forbidden") env.expect(t, http.StatusOK, http.MethodPost, path, `{}`, "cookie") } if calls := env.spy.take(); len(calls) != 2 { t.Fatalf("calls = %d, want only the two with the header", len(calls)) } }) t.Run("toolbar", func(t *testing.T) { rec := env.expect(t, http.StatusOK, http.MethodPost, toolbar, `{}`, "bearer") result := actResult(t, rec) if result.Message != "Gadgets were recounted." || result.Fill == nil || len(result.Fill) != 0 { t.Fatalf("toolbar result = %+v", result) } calls := env.spy.take() if len(calls) != 1 || !reflect.DeepEqual(calls[0], pact.AdminActionInput{}) { t.Fatalf("toolbar input = %+v", calls) } for _, name := range []string{"hidden", "create", "delete", "lookup", "missing"} { env.expect(t, http.StatusNotFound, http.MethodPost, "/acme/demo/gadgets/toolbar/"+name, `{}`, "bearer") } for _, body := range []string{fmt.Sprintf(`{"record_id":%d}`, mine), `{"values":{}}`, `{"values":{"name":"x"}}`} { env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, toolbar, body, "bearer") } if calls := env.spy.take(); len(calls) != 0 { t.Fatalf("refused toolbar calls ran: %+v", calls) } }) t.Run("partials", func(t *testing.T) { rec := env.expect(t, http.StatusOK, http.MethodGet, "/acme/demo/gadgets/partials/stats", "", "bearer") if !strings.Contains(rec.Body.String(), `"text":"All gadgets"`) || !strings.Contains(rec.Body.String(), `"text":"2"`) { t.Fatalf("stats = %s", rec.Body.String()) } rec = env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("/acme/demo/gadgets/partials/summary?id=%d", mine), "", "bearer") if !strings.Contains(rec.Body.String(), `"text":"mine"`) || !strings.Contains(rec.Body.String(), `"aria-label":"Summary"`) { t.Fatalf("summary = %s", rec.Body.String()) } rec = env.expect(t, http.StatusOK, http.MethodGet, "/acme/demo/gadgets/partials/summary", "", "bearer") if !strings.Contains(rec.Body.String(), `"tag":"p"`) || strings.Contains(rec.Body.String(), "mine") { t.Fatalf("create-form summary = %s", rec.Body.String()) } for _, rel := range []string{ "/acme/demo/gadgets/partials/missing", "/acme/demo/gadgets/partials/stats?id=" + fmt.Sprint(mine), "/acme/demo/gadgets/partials/summary?id=abc", "/acme/demo/gadgets/partials/summary?id=0", "/acme/demo/gadgets/partials/summary?id=-1", "/acme/demo/gadgets/partials/summary?id=" + fmt.Sprint(foreign), } { rec := env.expect(t, http.StatusNotFound, http.MethodGet, rel, "", "bearer") if strings.Contains(rec.Body.String(), "foreign") { t.Fatalf("%s leaked the record: %s", rel, rec.Body.String()) } } rec = env.expect(t, http.StatusInternalServerError, http.MethodGet, fmt.Sprintf("/acme/demo/gadgets/partials/summary?id=%d", explode), "", "bearer") actErrorCode(t, rec.Body.Bytes(), "error") if strings.Contains(rec.Body.String(), "view model failed") { t.Fatalf("500 leaked the error: %s", rec.Body.String()) } }) } func actErrorCode(t *testing.T, raw []byte, code string) { t.Helper() var body struct { Error struct { Code string `json:"code"` } `json:"error"` } if err := json.Unmarshal(raw, &body); err != nil || body.Error.Code != code { t.Fatalf("error code=%q, want %s (%v); body %s", body.Error.Code, code, err, raw) } }