package cabana import ( "bytes" "context" "encoding/json" "errors" "strings" "testing" "testing/fstest" "git.golem15.com/golem15/summercms/modules/pact" "gorm.io/gorm" ) const mlFormConfig = `name: posts form: ~/plugins/acme/demo/models/post/fields.yaml modelClass: Post ` const mlListConfig = `modelClass: Post list: ~/plugins/acme/demo/models/post/columns.yaml recordsPerPage: 20 toolbar: buttons: [create] ` const mlFields = `fields: title: label: Title type: mltext required: true ` const mlColumns = `columns: title: label: Title searchable: true ` type mlPost struct { ID uint `gorm:"column:id;primaryKey"` Title string `gorm:"column:title"` } func (mlPost) TableName() string { return "cabana_ml_posts" } func (mlPost) Fillable() []string { return []string{"title"} } func (mlPost) Rules() map[string]string { return map[string]string{"title": "required"} } type mlController struct{} func (mlController) ID() string { return "acme.demo.posts" } func (mlController) ModelName() string { return "Post" } func (mlController) ConfigDir() string { return "controllers/posts" } func (mlController) NewRecord() any { return &mlPost{} } func (mlController) FormExtendQuery(ctx context.Context, q *gorm.DB) *gorm.DB { return q } var ( _ pact.AdminController = mlController{} _ pact.AdminRecordSource = mlController{} _ pact.FormExtendQuery = mlController{} ) type recordingWriter struct { defaultLocale string enabled []string attrs map[string]map[string]string ids []uint } func (w *recordingWriter) DefaultLocale(context.Context, *gorm.DB) (string, error) { return w.defaultLocale, nil } func (w *recordingWriter) EnabledLocales(context.Context, *gorm.DB) ([]string, error) { return append([]string(nil), w.enabled...), nil } func (w *recordingWriter) WriteTranslated(_ context.Context, _ *gorm.DB, model any, field, locale, value string) error { post, _ := model.(*mlPost) if post == nil || post.ID == 0 { return &ValidationError{Details: map[string]any{field: []string{"translation writer ran before the host row had an id"}}} } w.ids = append(w.ids, post.ID) if w.attrs == nil { w.attrs = map[string]map[string]string{} } if w.attrs[locale] == nil { w.attrs[locale] = map[string]string{} } w.attrs[locale][field] = value return nil } func mlFS() fstest.MapFS { return fstest.MapFS{ "controllers/posts/config_list.yaml": &fstest.MapFile{Data: []byte(mlListConfig)}, "controllers/posts/config_form.yaml": &fstest.MapFile{Data: []byte(mlFormConfig)}, "models/post/columns.yaml": &fstest.MapFile{Data: []byte(mlColumns)}, "models/post/fields.yaml": &fstest.MapFile{Data: []byte(mlFields)}, } } func mlCompiled(t *testing.T) *CompiledController { t.Helper() reg, err := compileRegistry([]controllerRef{{ plugin: formPlugin{fsys: mlFS()}, ctl: mlController{}, }}) if err != nil { t.Fatalf("registry: %v", err) } cc, ok := reg.Get("acme.demo.posts") if !ok || cc.Form == nil { t.Fatalf("compiled controller missing form: %+v", cc) } return cc } func TestMLNestedSaveSmoke(t *testing.T) { t.Run("schema accepts markdown mltext mlmarkdown and rejects unknown", func(t *testing.T) { accepted := `fields: title: type: mltext label: Title excerpt: type: markdown label: Excerpt body: type: mlmarkdown label: Body ` schema, err := CompileForm("acme.demo", schemaController{model: "Widget"}, formFS(formConfig, accepted)) if err != nil { t.Fatalf("compile accepted types: %v", err) } got := map[string]string{} for _, field := range schema.Fields { got[field.Name] = field.Type } if got["title"] != "mltext" || got["excerpt"] != "markdown" || got["body"] != "mlmarkdown" { t.Fatalf("types = %v", got) } _, err = CompileForm("acme.demo", schemaController{model: "Widget"}, formFS(formConfig, "fields:\n title:\n type: mlunknown\n label: Title\n")) if err == nil || !strings.Contains(err.Error(), "unsupported type mlunknown") { t.Fatalf("unknown type err = %v", err) } }) t.Run("ProjectWritableFields still drops generic nested maps", func(t *testing.T) { cc := mlCompiled(t) projected := ProjectWritableFields(cc, map[string]any{ "title": map[string]any{"en": "Hello", "pl": "Witaj"}, "extra": map[string]any{"nested": true}, }) if len(projected) != 0 { t.Fatalf("unlifted nested maps reached projection: %#v", projected) } }) t.Run("save lifts locales, fills English, writes Polish", func(t *testing.T) { _, shared := newListService(t) db := shared if err := db.Migrator().DropTable(&mlPost{}); err != nil { t.Fatal(err) } if err := db.AutoMigrate(&mlPost{}); err != nil { t.Fatal(err) } writer := &recordingWriter{defaultLocale: "en", enabled: []string{"en", "pl"}} svc := CRUDService{DB: db, writer: writer} cc := mlCompiled(t) rec, err := svc.Create(context.Background(), cc, RecordInput{Body: map[string]any{ "title": map[string]any{"en": "Hello", "pl": "Witaj"}, "extra": map[string]any{"nested": true}, }}) if err != nil { t.Fatalf("create: %v", err) } if rec["title"] != "Hello" { t.Fatalf("projected title = %#v, want Hello", rec["title"]) } var row mlPost if err := db.First(&row).Error; err != nil { t.Fatal(err) } if row.Title != "Hello" { t.Fatalf("host title = %q, want Hello", row.Title) } if writer.attrs["en"] != nil { t.Fatalf("default locale duplicated in attributes: %#v", writer.attrs["en"]) } if writer.attrs["pl"]["title"] != "Witaj" { t.Fatalf("Polish attributes = %#v", writer.attrs) } raw, err := json.Marshal(writer.attrs["pl"]) if err != nil || string(raw) != `{"title":"Witaj"}` { t.Fatalf("attribute JSON = %s err=%v", raw, err) } if len(writer.ids) != 1 || writer.ids[0] != row.ID || row.ID == 0 { t.Fatalf("writer ids = %v host id = %d", writer.ids, row.ID) } _, err = svc.Create(context.Background(), cc, RecordInput{Body: map[string]any{ "title": map[string]any{"en": "Hello", "de": "Hallo"}, }}) if err == nil { t.Fatal("undeclared locale succeeded") } var ve *ValidationError if !asValidation(err, &ve) || ve.Details["title"] == nil { t.Fatalf("undeclared locale err = %v", err) } }) t.Run("unsafe markdown cannot become executable HTML", func(t *testing.T) { if _, err := RenderMarkdown(""); err == nil || !strings.Contains(err.Error(), "raw unsafe tags") { // goldmark without WithUnsafe drops the raw tag; leftover HTML // must still be rejected if a later renderer change lets it through. html, convErr := renderMarkdownUnchecked("") if convErr != nil { t.Fatal(convErr) } if strings.Contains(strings.ToLower(html), "`); err == nil { html, _ := renderMarkdownUnchecked(``) if strings.Contains(strings.ToLower(html), "onerror") { t.Fatalf("event handler survived: %s", html) } } if html, err := RenderMarkdown("# Hello"); err != nil || !strings.Contains(html, "

") { t.Fatalf("safe markdown: html=%s err=%v", html, err) } }) } func asValidation(err error, dest **ValidationError) bool { return errors.As(err, dest) } func renderMarkdownUnchecked(src string) (string, error) { var buf bytes.Buffer if err := markdownEngine.Convert([]byte(src), &buf); err != nil { return "", err } return buf.String(), nil }