package cabana import ( "bytes" "context" "encoding/json" "errors" "log/slog" "net/http" "net/http/httptest" "reflect" "strings" "testing" "testing/fstest" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/pact" "gorm.io/gorm" ) // roNotesFields declares the notes type four times: without a context and // once each for the create, update and preview screens. Every one sets rows, // which the type's request options also name. const roNotesFields = ftNotesField + " notes_create:\n type: notes\n label: Create\n context: create\n rows: 4\n" + " notes_update:\n type: notes\n label: Update\n context: update\n rows: 4\n" + " notes_preview:\n type: notes\n label: Preview\n context: preview\n rows: 4\n" // roNote is the model of the request options controller: one column per // notes field of roNotesFields. type roNote struct { ID uint `gorm:"column:id;primaryKey"` Title string `gorm:"column:title"` Notes string `gorm:"column:notes"` NotesCreate string `gorm:"column:notes_create"` NotesUpdate string `gorm:"column:notes_update"` NotesPreview string `gorm:"column:notes_preview"` } func (*roNote) Fillable() []string { return []string{"title", "notes", "notes_create", "notes_update", "notes_preview"} } func (*roNote) Rules() map[string]string { return map[string]string{} } // roController is ftController editing roNote. type roController struct{ ftController } func (roController) NewRecord() any { return &roNote{} } // roPlugin is ftPlugin with roController. type roPlugin struct{ ftPlugin } func (roPlugin) AdminControllers() []pact.AdminController { return []pact.AdminController{roController{}} } // roDemo is the request options plugin with the notes type typ. func roDemo(typ pact.FormFieldType) roPlugin { return roPlugin{ftPlugin{id: "acme.demo", fsys: ftFS(roNotesFields), types: []pact.FormFieldType{typ}}} } // roCounter counts RequestOptions calls. type roCounter struct{ calls int } // roNotesType is the notes type whose request options add headingLevels and // try to override rows; err makes them fail. func roNotesType(counter *roCounter, err error) pact.FormFieldType { typ := ftNotesType() typ.Options = []string{"rows", "headingLevels"} typ.RequestOptions = func(_ context.Context, _ *gorm.DB) (map[string]any, error) { counter.calls++ if err != nil { return nil, err } return map[string]any{"headingLevels": []any{2, 3, 4}, "rows": 9}, nil } return typ } // roSchema serves the form schema of acme.demo.notes as principal and // returns the status, the fields by name and the raw body. func roSchema(t *testing.T, svc *service, principal *bouncer.Principal) (int, map[string]map[string]any, []byte) { t.Helper() req := httptest.NewRequest(http.MethodGet, adminAPI("/acme/demo/notes/schema/form"), nil) if principal != nil { req = req.WithContext(bouncer.WithUser(req.Context(), principal)) } req.SetPathValue("vendor", "acme") req.SetPathValue("plugin", "demo") req.SetPathValue("controller", "notes") rec := httptest.NewRecorder() svc.formSchema(rec, req) return rec.Code, roFields(t, rec.Body.Bytes()), rec.Body.Bytes() } // roFields indexes a form schema response's fields by name. func roFields(t *testing.T, raw []byte) map[string]map[string]any { t.Helper() var body struct { Data struct { Fields []map[string]any `json:"fields"` } `json:"data"` } if err := json.Unmarshal(raw, &body); err != nil { return nil } out := map[string]map[string]any{} for _, field := range body.Data.Fields { name, _ := field["name"].(string) out[name] = field } return out } func roAdmin() *bouncer.Principal { return &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true} } // roCaptureLog sends the default logger to a buffer for the test. func roCaptureLog(t *testing.T) *bytes.Buffer { t.Helper() var buf bytes.Buffer previous := slog.Default() slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil))) t.Cleanup(func() { slog.SetDefault(previous) }) return &buf } // TestFieldTypeRequestOptions: a plugin field type's RequestOptions reach // the served form schema per request, under the fields.yaml options, for // every screen and for a form extension's field; an error fails open; a type // without them serves the schema as before. func TestFieldTypeRequestOptions(t *testing.T) { t.Run("create, update and preview fields carry the request options", func(t *testing.T) { counter := &roCounter{} reg, err := ftCompile(roDemo(roNotesType(counter, nil))) if err != nil { t.Fatalf("compile: %v", err) } code, fields, raw := roSchema(t, &service{reg: reg}, roAdmin()) if code != http.StatusOK { t.Fatalf("status=%d body=%s", code, raw) } for _, name := range []string{"notes", "notes_create", "notes_update", "notes_preview"} { opts, _ := fields[name]["typeOptions"].(map[string]any) if !reflect.DeepEqual(opts["headingLevels"], []any{float64(2), float64(3), float64(4)}) { t.Fatalf("%s typeOptions=%v", name, fields[name]["typeOptions"]) } } }) t.Run("an option set in fields.yaml wins over the request value", func(t *testing.T) { counter := &roCounter{} reg, err := ftCompile(roDemo(roNotesType(counter, nil))) if err != nil { t.Fatalf("compile: %v", err) } _, fields, _ := roSchema(t, &service{reg: reg}, roAdmin()) if opts, _ := fields["notes"]["typeOptions"].(map[string]any); opts["rows"] != float64(4) { t.Fatalf("typeOptions=%v, want rows 4 from fields.yaml", fields["notes"]["typeOptions"]) } }) t.Run("RequestOptions runs once per schema request for each type", func(t *testing.T) { counter := &roCounter{} reg, err := ftCompile(roDemo(roNotesType(counter, nil))) if err != nil { t.Fatalf("compile: %v", err) } svc := &service{reg: reg} roSchema(t, svc, roAdmin()) if counter.calls != 1 { t.Fatalf("calls=%d after one request with four fields of the type, want 1", counter.calls) } roSchema(t, svc, roAdmin()) if counter.calls != 2 { t.Fatalf("calls=%d after two requests, want 2", counter.calls) } }) t.Run("the compiled schema is never changed", func(t *testing.T) { counter := &roCounter{} reg, err := ftCompile(roDemo(roNotesType(counter, nil))) if err != nil { t.Fatalf("compile: %v", err) } cc, _ := reg.Get("acme.demo.notes") before, _ := cc.Form.CanonicalJSON() roSchema(t, &service{reg: reg}, roAdmin()) after, _ := cc.Form.CanonicalJSON() if !bytes.Equal(before, after) || strings.Contains(string(after), "headingLevels") { t.Fatalf("compiled schema changed:\n%s\n%s", before, after) } }) t.Run("an error serves the fields.yaml options and logs one warning", func(t *testing.T) { buf := roCaptureLog(t) counter := &roCounter{} reg, err := ftCompile(roDemo(roNotesType(counter, errors.New("settings unavailable")))) if err != nil { t.Fatalf("compile: %v", err) } code, fields, raw := roSchema(t, &service{reg: reg}, roAdmin()) if code != http.StatusOK { t.Fatalf("status=%d body=%s", code, raw) } for _, name := range []string{"notes", "notes_create", "notes_update", "notes_preview"} { if opts, _ := fields[name]["typeOptions"].(map[string]any); !reflect.DeepEqual(opts, map[string]any{"rows": float64(4)}) { t.Fatalf("%s typeOptions=%v, want the fields.yaml options only", name, fields[name]["typeOptions"]) } } logged := buf.String() if n := strings.Count(logged, "request options failed"); n != 1 || !strings.Contains(logged, "notes") || !strings.Contains(logged, "settings unavailable") { t.Fatalf("want one warning naming the type, got %d:\n%s", n, logged) } if !strings.Contains(logged, "level=WARN") { t.Fatalf("the warning is not at WARN level:\n%s", logged) } }) t.Run("a type without RequestOptions serves the schema unchanged", func(t *testing.T) { reg, err := ftCompile(ftDemo(ftNotesField)) if err != nil { t.Fatalf("compile: %v", err) } cc, _ := reg.Get("acme.demo.notes") view, err := cc.Form.Localize(context.Background(), nil, dropdownProvider(cc.Controller)) if err != nil { t.Fatal(err) } want, _ := view.CanonicalJSON() code, _, raw := roSchema(t, &service{reg: reg}, roAdmin()) if code != http.StatusOK { t.Fatalf("status=%d body=%s", code, raw) } var body struct { Data FormView `json:"data"` } if err := json.Unmarshal(raw, &body); err != nil { t.Fatal(err) } body.Data.Assets = ControllerAssets{} view.Assets = ControllerAssets{} got, _ := body.Data.CanonicalJSON() want, _ = view.CanonicalJSON() if !bytes.Equal(got, want) { t.Fatalf("served fields differ from the compiled schema:\n%s\n%s", got, want) } }) t.Run("a form extension's plugin field gets its request options", func(t *testing.T) { counter := &roCounter{} ext := feExt("acme.extra", nil, "acme.blog.posts", "fields:\n note:\n type: extranote\n label: Note\n after: a\n mode2: on\n") typ := feWidgetType() typ.Options = []string{"mode2"} typ.RequestOptions = func(context.Context, *gorm.DB) (map[string]any, error) { counter.calls++ return map[string]any{"genericAnchors": []any{"here"}, "mode2": "request"}, nil } ext.fsys = fstest.MapFS{"assets/js/extra.js": &fstest.MapFile{Data: []byte("// extra\n")}} ext.types = []pact.FormFieldType{typ} reg, err := feCompile(feBlog(), ext) if err != nil { t.Fatalf("compile: %v", err) } rec := httptest.NewRecorder() (&service{reg: reg}).formSchema(rec, feRequest(roAdmin(), http.MethodGet, adminAPI("/acme/blog/posts/schema/form"), nil, nil)) if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } opts, _ := roFields(t, rec.Body.Bytes())["note"]["typeOptions"].(map[string]any) if !reflect.DeepEqual(opts["genericAnchors"], []any{"here"}) || opts["mode2"] != "on" || counter.calls != 1 { t.Fatalf("typeOptions=%v calls=%d", opts, counter.calls) } }) t.Run("only the authenticated schema handler calls RequestOptions", func(t *testing.T) { counter := &roCounter{} reg, err := ftCompile(roDemo(roNotesType(counter, nil))) if err != nil { t.Fatalf("compile: %v", err) } code, _, raw := roSchema(t, &service{reg: reg}, nil) if code != http.StatusUnauthorized || counter.calls != 0 { t.Fatalf("status=%d calls=%d body=%s", code, counter.calls, raw) } code, _, _ = roSchema(t, &service{reg: reg}, &bouncer.Principal{ID: 2, Backend: true}) if code != http.StatusForbidden || counter.calls != 0 { t.Fatalf("an administrator without the controller permission: status=%d calls=%d", code, counter.calls) } }) }