diff --git a/admin/tests/form/MarkdownField.test.ts b/admin/tests/form/MarkdownField.test.ts new file mode 100644 index 0000000..d2459e0 --- /dev/null +++ b/admin/tests/form/MarkdownField.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { enableAutoUnmount, mount } from '@vue/test-utils' +import type { FormField } from '../../src/api/types' +import MarkdownField from '../../src/components/form/fields/MarkdownField.vue' +import MLMarkdownField from '../../src/components/form/fields/MLMarkdownField.vue' +import { resetState } from '../helpers' + +function field(type = 'markdown', name = 'body'): FormField { + return { name, label: 'Body', type } as FormField +} + +beforeEach(() => { + resetState() +}) + +afterEach(() => { + document.body.innerHTML = '' +}) + +enableAutoUnmount(afterEach) + +describe('MarkdownField composition and sinks', () => { + it('edits source and previews interpolated text without v-html', async () => { + const wrapper = mount(MarkdownField, { + props: { + field: field(), + modelValue: '# Hello', + controlId: 'f-body', + }, + attachTo: document.body, + }) + expect(wrapper.find('textarea').element.value).toBe('# Hello') + await wrapper.find('textarea').setValue('# Changed') + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['# Changed']) + await wrapper.setProps({ modelValue: '# Changed' }) + await wrapper.find('[data-markdown-preview]').trigger('click') + expect(wrapper.find('[data-markdown-preview-pane]').text()).toBe('# Changed') + expect(wrapper.find('h1').exists()).toBe(false) + }) + + it('does not execute script, iframe, or event-handler HTML', async () => { + const wrapper = mount(MarkdownField, { + props: { + field: field(), + modelValue: + '', + controlId: 'f-body', + }, + attachTo: document.body, + }) + await wrapper.find('[data-markdown-preview]').trigger('click') + expect(wrapper.find('script').exists()).toBe(false) + expect(wrapper.find('iframe').exists()).toBe(false) + expect(wrapper.find('img').exists()).toBe(false) + expect((window as unknown as { __md_xss?: number }).__md_xss).toBeUndefined() + expect(wrapper.html()).not.toMatch(/v-html|innerHTML/) + }) + + it('composes inside mlmarkdown without a raw-HTML sink', async () => { + const wrapper = mount(MLMarkdownField, { + props: { + field: field('mlmarkdown', 'body'), + modelValue: { en: '# Hi', pl: '' }, + controlId: 'f-body', + }, + attachTo: document.body, + }) + expect(wrapper.findComponent(MarkdownField).exists()).toBe(true) + await wrapper.find('[data-ml-locale]').setValue('pl') + await wrapper.find('[data-markdown-preview]').trigger('click') + expect(wrapper.find('script').exists()).toBe(false) + expect((window as unknown as { __md_xss?: number }).__md_xss).toBeUndefined() + expect(wrapper.find('[data-markdown-preview-pane]').text()).toContain('", "`, "`, "onerror"}, + {"javascript", "[x](javascript:alert(1))", "javascript:"}, + {"vbscript", "[x](vbscript:msgbox(1))", "vbscript:"}, + {"data", "[x](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)", "data:"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + html, err := RenderMarkdown(tc.src) + if err == nil && strings.Contains(strings.ToLower(html), tc.needle) { + t.Fatalf("unsafe HTML survived: %s", html) + } + unchecked, convErr := renderMarkdownUnchecked(tc.src) + if convErr != nil { + t.Fatal(convErr) + } + if strings.Contains(strings.ToLower(unchecked), tc.needle) && err == nil { + t.Fatalf("RenderMarkdown accepted %s: %s", tc.name, html) + } + }) + } +} diff --git a/modules/cabana/ml_test.go b/modules/cabana/ml_test.go new file mode 100644 index 0000000..e68771f --- /dev/null +++ b/modules/cabana/ml_test.go @@ -0,0 +1,191 @@ +package cabana + +import ( + "context" + "errors" + "strings" + "testing" + + "git.golem15.com/golem15/summercms/modules/pact" + "gorm.io/gorm" +) + +func TestMLFieldTypes(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) + } +} + +func TestMLNestedSave(t *testing.T) { + _, db := newListService(t) + 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) + + 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) + } + + 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", 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", row.Title) + } + if writer.attrs["en"] != nil { + t.Fatalf("default locale duplicated: %#v", writer.attrs["en"]) + } + if writer.attrs["pl"]["title"] != "Witaj" { + t.Fatalf("Polish attributes = %#v", writer.attrs) + } + + _, 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") + } + _, err = svc.Create(context.Background(), cc, RecordInput{Body: map[string]any{ + "title": map[string]any{"en": 1, "pl": "x"}, + }}) + if err == nil { + t.Fatal("non-string locale value succeeded") + } +} + +func TestML(t *testing.T) { + t.Run("writer failure rolls back host write", func(t *testing.T) { + _, db := newListService(t) + if err := db.Migrator().DropTable(&mlPost{}); err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&mlPost{}); err != nil { + t.Fatal(err) + } + writer := &boomWriter{recordingWriter: recordingWriter{defaultLocale: "en", enabled: []string{"en", "pl"}}} + svc := CRUDService{DB: db, writer: writer} + _, err := svc.Create(context.Background(), mlCompiled(t), RecordInput{Body: map[string]any{ + "title": map[string]any{"en": "Hello", "pl": "Witaj"}, + }}) + if err == nil { + t.Fatal("writer failure succeeded") + } + if writer.calls == 0 { + t.Fatal("writer was not invoked") + } + var n int64 + if err := db.Model(&mlPost{}).Count(&n).Error; err != nil { + t.Fatal(err) + } + if n != 0 { + t.Fatalf("host rows after writer failure = %d", n) + } + }) + + t.Run("writer is not invoked before query checks", func(t *testing.T) { + _, db := newListService(t) + if err := db.Migrator().DropTable(&mlPost{}); err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&mlPost{}); err != nil { + t.Fatal(err) + } + if err := db.Create(&mlPost{Title: "Hello"}).Error; err != nil { + t.Fatal(err) + } + writer := &recordingWriter{defaultLocale: "en", enabled: []string{"en", "pl"}} + svc := CRUDService{DB: db, writer: writer} + cc := scopedMLCompiled(t) + _, err := svc.Update(context.Background(), cc, 1, RecordInput{Body: map[string]any{ + "title": map[string]any{"en": "Hello", "pl": "Witaj"}, + }}) + if err == nil { + t.Fatal("out-of-scope update succeeded") + } + if len(writer.ids) != 0 { + t.Fatalf("writer ran before query scope: %v", writer.ids) + } + }) +} + +type boomWriter struct { + recordingWriter + calls int +} + +func (w *boomWriter) WriteTranslated(ctx context.Context, tx *gorm.DB, model any, field, locale, value string) error { + w.calls++ + return errors.New("writer failed") +} + +type scopedMLController struct{ mlController } + +func (scopedMLController) FormExtendQuery(_ context.Context, q *gorm.DB) *gorm.DB { + return q.Where("1 = 0") +} + +func scopedMLCompiled(t *testing.T) *CompiledController { + t.Helper() + reg, err := compileRegistry([]controllerRef{{ + plugin: formPlugin{fsys: mlFS()}, + ctl: scopedMLController{}, + }}) + 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 +} + +var ( + _ pact.AdminController = scopedMLController{} + _ pact.AdminRecordSource = scopedMLController{} + _ pact.FormExtendQuery = scopedMLController{} +) diff --git a/modules/cabana/openapi_conformance_test.go b/modules/cabana/openapi_conformance_test.go index 371bd55..b9ca926 100644 --- a/modules/cabana/openapi_conformance_test.go +++ b/modules/cabana/openapi_conformance_test.go @@ -75,6 +75,57 @@ func into[T any]() func(*json.Decoder) error { // 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 TestMLOpenAPIConformance(t *testing.T) { + spec := conformSpec(t) + if got := spec.ref("/{vendor}/{plugin}/{controller}/schema/form", "get", "200"); got != "cabana.Envelope-cabana_FormView" { + t.Fatalf("form schema ref = %q", got) + } + _, 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) + } + doc := string(raw) + for _, name := range []string{"cabana.FormField", "cabana.FormView"} { + if !strings.Contains(doc, `"`+name+`"`) { + t.Fatalf("admin.json missing %s", name) + } + } + schema, err := cabana.CompileForm("acme.demo", stubMLSchemaController{}, fstest.MapFS{ + "controllers/widgets/config_form.yaml": {Data: []byte("name: posts\nform: ~/plugins/acme/demo/models/widget/fields.yaml\nmodelClass: Widget\n")}, + "models/widget/fields.yaml": {Data: []byte(`fields: + title: + type: mltext + label: Title + excerpt: + type: markdown + label: Excerpt + body: + type: mlmarkdown + label: Body +`)}, + }) + if err != nil { + t.Fatal(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("compiled types = %v", got) + } +} + +type stubMLSchemaController struct{} + +func (stubMLSchemaController) ID() string { return "acme.demo.widgets" } +func (stubMLSchemaController) ModelName() string { return "Widget" } +func (stubMLSchemaController) ConfigDir() string { return "controllers/widgets" } + func TestPhase10OpenAPIConformance(t *testing.T) { env := newConformEnv(t) spec := conformSpec(t) diff --git a/modules/surf/locale_resolver_test.go b/modules/surf/locale_resolver_test.go index 815fe32..e22db11 100644 --- a/modules/surf/locale_resolver_test.go +++ b/modules/surf/locale_resolver_test.go @@ -1,8 +1,11 @@ package surf import ( + "context" + "fmt" "net/http" "net/http/httptest" + "sync" "testing" "git.golem15.com/golem15/summercms/modules/backpack" @@ -59,6 +62,93 @@ func TestLocaleResolverWritesValidatedCodeAndStripsPrefix(t *testing.T) { } } +func TestLocaleResolver(t *testing.T) { + t.Run("present writes validated code", TestLocaleResolverWritesValidatedCodeAndStripsPrefix) + t.Run("absent keeps Accept-Language", TestLocaleAbsentResolverKeepsAcceptLanguage) + t.Run("conflicting requests stay isolated", TestLocaleResolverRequestIsolation) +} + +func TestLocaleResolverRequestIsolation(t *testing.T) { + cfg := writeHTTPConfig(t, "body_limits:\n default_bytes: 1024\n upload_bytes: 1024\n") + app := backpack.New(cfg) + if err := app.Publish[LocaleResolver](pathLocaleResolver{}); err != nil { + t.Fatal(err) + } + r := New(nil) + r.app = app + r.defaultBytes = 1024 + r.BindPlugin("acme.blog") + var mu sync.Mutex + got := map[string]string{} + r.Get("/posts", func(w http.ResponseWriter, req *http.Request) { + code, _ := towel.Locale(req.Context()) + mu.Lock() + got[req.Header.Get("X-Req")] = code + mu.Unlock() + w.WriteHeader(http.StatusNoContent) + }) + h, err := r.compile() + if err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + for i, code := range []string{"pl", "en", "pl", "en"} { + wg.Add(1) + go func(i int, code string) { + defer wg.Done() + id := fmt.Sprintf("%s-%d", code, i) + req := httptest.NewRequest(http.MethodGet, "/"+code+"/posts", nil) + req.Header.Set("X-Req", id) + req.Header.Set("Accept-Language", "de") + h.ServeHTTP(httptest.NewRecorder(), req) + mu.Lock() + if got[id] != code { + t.Errorf("request %s locale = %q", id, got[id]) + } + mu.Unlock() + }(i, code) + } + wg.Wait() +} + +type pathLocaleResolver struct{} + +func (pathLocaleResolver) Resolve(_ http.ResponseWriter, r *http.Request) string { + if r == nil { + return "en" + } + if v := r.Context().Value(stubPrefixKey{}); v != nil { + if code, ok := v.(string); ok && code != "" { + return code + } + } + path := r.URL.Path + if len(path) >= 3 && path[0] == '/' && (path[1:3] == "pl" || path[1:3] == "en") { + return path[1:3] + } + return "en" +} + +func (pathLocaleResolver) Rewrite(r *http.Request) *http.Request { + if r == nil { + return r + } + path := r.URL.Path + if len(path) >= 3 && path[0] == '/' && (path[1:3] == "pl" || path[1:3] == "en") && (len(path) == 3 || path[3] == '/') { + code := path[1:3] + clone := r.Clone(context.WithValue(r.Context(), stubPrefixKey{}, code)) + if len(path) == 3 { + clone.URL.Path = "/" + } else { + clone.URL.Path = path[3:] + } + return clone + } + return r +} + +type stubPrefixKey struct{} + func TestLocaleAbsentResolverKeepsAcceptLanguage(t *testing.T) { r := New(nil) r.BindPlugin("acme.blog")