Form schema meta lists enabled locales, Show/save expand mltext maps without D-11 fallback, and the SPA seeds and adopt-merges every locale. Co-authored-by: Cursor <cursoragent@cursor.com>
286 lines
8.5 KiB
Go
286 lines
8.5 KiB
Go
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 (w *recordingWriter) TranslatedExact(_ context.Context, _ *gorm.DB, model any, field, locale string) (string, bool, error) {
|
|
if locale == w.defaultLocale {
|
|
post, _ := model.(*mlPost)
|
|
if post == nil {
|
|
return "", false, nil
|
|
}
|
|
return post.Title, true, nil
|
|
}
|
|
if w.attrs == nil {
|
|
return "", false, nil
|
|
}
|
|
vals := w.attrs[locale]
|
|
if vals == nil {
|
|
return "", false, nil
|
|
}
|
|
text, ok := vals[field]
|
|
return text, ok, 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"] != nil {
|
|
got, ok := rec["title"].(map[string]string)
|
|
if !ok || got["en"] != "Hello" || got["pl"] != "Witaj" {
|
|
t.Fatalf("projected title = %#v, want hydrated en+pl map", rec["title"])
|
|
}
|
|
} else {
|
|
t.Fatalf("projected title = %#v, want hydrated en+pl map", 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("<script>alert(1)</script>"); 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("<script>alert(1)</script>")
|
|
if convErr != nil {
|
|
t.Fatal(convErr)
|
|
}
|
|
if strings.Contains(strings.ToLower(html), "<script") {
|
|
if err == nil {
|
|
t.Fatalf("RenderMarkdown accepted script HTML: %s", html)
|
|
}
|
|
}
|
|
}
|
|
if html, err := RenderMarkdown("[x](javascript:alert(1))"); err == nil && strings.Contains(strings.ToLower(html), "javascript:") {
|
|
t.Fatalf("javascript URL survived: %s", html)
|
|
} else if err == nil && strings.Contains(strings.ToLower(html), "<script") {
|
|
t.Fatalf("script tag survived: %s", html)
|
|
}
|
|
if _, err := RenderMarkdown(`<img src=x onerror="alert(1)">`); err == nil {
|
|
html, _ := renderMarkdownUnchecked(`<img src=x onerror="alert(1)">`)
|
|
if strings.Contains(strings.ToLower(html), "onerror") {
|
|
t.Fatalf("event handler survived: %s", html)
|
|
}
|
|
}
|
|
if html, err := RenderMarkdown("# Hello"); err != nil || !strings.Contains(html, "<h1>") {
|
|
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
|
|
}
|