package journal_test import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" "git.golem15.com/golem15/sm-journal-plugin/controllers" "git.golem15.com/golem15/sm-journal-plugin/models" ) func TestPostsFormCompiles(t *testing.T) { env := newPostsEnv(t) token := env.admin(t, controllers.PermissionAccessPosts) rec := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminPostsPath+"/schema/form"), token, nil) body := rec.Body.String() if !strings.Contains(body, `"type":"mlblogeditor"`) { t.Fatalf("form schema missing mlblogeditor: %s", body) } if !strings.Contains(body, "mltext") { t.Fatalf("form schema missing mltext: %s", body) } if !strings.Contains(body, "mltextarea") { t.Fatalf("form schema missing mltextarea: %s", body) } if !strings.Contains(body, `"preset":{"field":"title","type":"slug"}`) { t.Fatalf("form schema missing slug preset: %s", body) } } func TestPostsAdminCreateSmoke(t *testing.T) { env := newPostsEnv(t) privileged := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessPublish, controllers.PermissionAccessOtherPosts, ) rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), privileged, map[string]any{ "title": map[string]any{"en": "Hello", "pl": "Witaj"}, "slug": map[string]any{"en": "hello-admin", "pl": "witaj-admin"}, "content": map[string]any{"en": "

Hello world

", "pl": "

Witaj świecie

"}, "excerpt": map[string]any{"en": "Excerpt", "pl": "Wyjątek"}, }) if notices := adminNotices(t, rec); notices != nil { t.Fatalf("clean create carries meta.notices: %s", rec.Body.String()) } var created struct { ID uint `json:"id"` } envData := adminDecode(t, rec) if err := json.Unmarshal(envData.Data, &created); err != nil || created.ID == 0 { t.Fatalf("create data %s: %v", rec.Body.String(), err) } var stored models.Post if err := env.gdb.First(&stored, created.ID).Error; err != nil { t.Fatal(err) } if stored.Title != "Hello" { t.Fatalf("host title = %q, want Hello", stored.Title) } if stored.Slug != "hello-admin" { t.Fatalf("host slug = %q, want hello-admin", stored.Slug) } // D-02: the admin path stores the same HTML in both columns. if stored.Content != "

Hello world

" || stored.ContentHTML != stored.Content { t.Fatalf("content = %q, content_html = %q, want both the submitted HTML", stored.Content, stored.ContentHTML) } var plCount int64 if err := env.gdb.Table("golem15_translate_attributes"). Where("model_id = ? AND model_type = ? AND locale = ?", fmt.Sprint(stored.ID), stored.MorphName(), "pl"). Count(&plCount).Error; err != nil { t.Fatal(err) } if plCount == 0 { t.Fatal("Polish translate attributes were not written") } t.Run("publish without access_publish", func(t *testing.T) { limited := env.admin(t, controllers.PermissionAccessPosts) denied := env.call(t, http.MethodPost, adminAPI(adminPostsPath), limited, map[string]any{ "title": map[string]any{"en": "Draft", "pl": "Szkic"}, "slug": map[string]any{"en": "draft-admin", "pl": "szkic-admin"}, "content": map[string]any{"en": "Draft body", "pl": "Treść szkicu"}, "published": true, }) if denied.Code != http.StatusForbidden { t.Fatalf("publish without access_publish = %d, want 403: %s", denied.Code, denied.Body.String()) } if adminDecode(t, denied).Error.Code != "forbidden" { t.Fatalf("error code = %s", denied.Body.String()) } var n int64 if err := env.gdb.Model(&models.Post{}).Where("slug = ?", "draft-admin").Count(&n).Error; err != nil { t.Fatal(err) } if n != 0 { t.Fatal("published=true persisted without access_publish") } }) } // TestPostsAdminResaveKeepsHTML is the acceptance test of the todo // journal-imported-html-admin-resave (Phase 16.2 D-03, D-07) on the real // admin API: the administrator opens a post and saves it without touching // the editor. The test reads the form values the API serves with GET and // PUTs them back unchanged, as the admin SPA does. // // - An HTML post keeps content and content_html byte for byte, gains no // translated content for its untouched empty Polish value, and the // response has no meta.notices key. // - A markdown post (content markdown, content_html rendered at import, as // Płytarium-era imports are) is served as content_html (D-07) and keeps // its markdown content and its content_html byte for byte in both // locales, even though its content_html holds markup the policy would // strip: an untouched value is never decoded. // - An edited save of clean HTML in both locales stores equal content and // content_html per locale and reports nothing. // // The harness's default locale is en (host columns); pl is the translation. func TestPostsAdminResaveKeepsHTML(t *testing.T) { env := newPostsEnv(t) token := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessPublish, controllers.PermissionAccessOtherPosts, ) t.Run("HTML post", func(t *testing.T) { const content = "

Wstęp

\n" + "

Grzyby rosną w lesie.

\n" + "

Druga część z linkiem.

\n" + "
\n" rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), token, map[string]any{ "title": map[string]any{"en": "Imported", "pl": "Zaimportowany"}, "slug": map[string]any{"en": "imported-html", "pl": "zaimportowany-html"}, "content": map[string]any{"en": content}, "excerpt": map[string]any{"en": "Excerpt", "pl": "Wyjątek"}, }) if notices := adminNotices(t, rec); notices != nil { t.Fatalf("clean create carries meta.notices: %s", rec.Body.String()) } id := adminCreatedID(t, rec) assertHostContent(t, env, id, "create", content, content) before := translatedAttrs(t, env, id, "pl") rowsBefore := translatedRows(t, env, id) served := servedForm(t, env, token, id) if got := servedLocales(t, served, "content"); got["en"] != content || got["pl"] != "" { t.Fatalf("served content = %#v, want en as stored and an empty pl", got) } put := env.expect(t, http.StatusOK, http.MethodPut, adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, id)), token, servedBody(served, "title", "slug", "content", "excerpt")) if _, ok := adminMeta(t, put)["notices"]; ok { t.Fatalf("untouched save carries meta.notices: %s", put.Body.String()) } assertHostContent(t, env, id, "untouched re-save", content, content) after := translatedAttrs(t, env, id, "pl") for _, key := range []string{"content", "content_html"} { old, had := before[key] now, has := after[key] if had != has || old != now { t.Fatalf("untouched re-save changed pl %s: before %#v (%v), after %#v (%v)", key, old, had, now, has) } if has { t.Fatalf("pl %s written for an untouched empty value: %#v", key, now) } } if rows := translatedRows(t, env, id); rows != rowsBefore { t.Fatalf("untouched re-save added translation rows: %d -> %d", rowsBefore, rows) } const editedEN = "

Edited English text.

\n
  1. three
\n" const editedPL = "

Poprawiony polski tekst.

\n

\"grzyb\"

\n" editBody := servedBody(served, "title", "slug", "excerpt") editBody["content"] = map[string]any{"en": editedEN, "pl": editedPL} edit := env.expect(t, http.StatusOK, http.MethodPut, adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, id)), token, editBody) if _, ok := adminMeta(t, edit)["notices"]; ok { t.Fatalf("clean edited save carries meta.notices: %s", edit.Body.String()) } assertHostContent(t, env, id, "edited save", editedEN, editedEN) pl := translatedAttrs(t, env, id, "pl") if pl["content"] != editedPL || pl["content_html"] != pl["content"] { t.Fatalf("pl content = %#v, content_html = %#v, want both %q", pl["content"], pl["content_html"], editedPL) } }) t.Run("markdown post", func(t *testing.T) { const markdownEN = "## Heading\n\nText with *italics* and **bold**.\n\n- one\n- two\n" const markdownPL = "## Nagłówek\n\nTekst z *kursywą* i **pogrubieniem**.\n\n- jeden\n- dwa\n" // The content_html an import stored: rendered markdown plus the raw // author footer and its style block, which the policy would strip. const footer = "\n\n" const renderedEN = "

Heading

\n

Text with italics and bold.

\n\n" + footer const renderedPL = "

Nagłówek

\n

Tekst z kursywą i pogrubieniem.

\n\n" + footer rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), token, map[string]any{ "title": map[string]any{"en": "Markdown", "pl": "Markdown PL"}, "slug": map[string]any{"en": "imported-markdown", "pl": "zaimportowany-markdown"}, "content": map[string]any{"en": markdownEN, "pl": markdownPL}, }) id := adminCreatedID(t, rec) assertHostContent(t, env, id, "markdown create", markdownEN, markdownEN) // What an import leaves behind: content_html rendered from markdown. if err := env.gdb.Model(&models.Post{}).Where("id = ?", id).Update("content_html", renderedEN).Error; err != nil { t.Fatal(err) } setTranslatedAttr(t, env, id, "pl", "content_html", renderedPL) served := servedForm(t, env, token, id) if got := servedLocales(t, served, "content"); got["en"] != renderedEN || got["pl"] != renderedPL { t.Fatalf("served content = %#v, want each locale's content_html (D-07)", got) } put := env.expect(t, http.StatusOK, http.MethodPut, adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, id)), token, servedBody(served, "title", "slug", "content", "excerpt")) if _, ok := adminMeta(t, put)["notices"]; ok { t.Fatalf("untouched markdown save carries meta.notices: %s", put.Body.String()) } assertHostContent(t, env, id, "untouched markdown re-save", markdownEN, renderedEN) pl := translatedAttrs(t, env, id, "pl") if pl["content"] != markdownPL || pl["content_html"] != renderedPL { t.Fatalf("pl content = %#v, content_html = %#v, want the markdown and the imported HTML unchanged", pl["content"], pl["content_html"]) } }) } // adminCreatedID returns the id of a create response's record. func adminCreatedID(t *testing.T, rec *httptest.ResponseRecorder) uint { t.Helper() var created struct { ID uint `json:"id"` } if err := json.Unmarshal(adminDecode(t, rec).Data, &created); err != nil || created.ID == 0 { t.Fatalf("create data %s: %v", rec.Body.String(), err) } return created.ID } // adminMeta returns a response's meta object by key. func adminMeta(t *testing.T, rec *httptest.ResponseRecorder) map[string]json.RawMessage { t.Helper() var body struct { Meta map[string]json.RawMessage `json:"meta"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("admin body %q: %v", rec.Body.String(), err) } return body.Meta } // assertHostContent compares the host columns byte for byte (no trimming). func assertHostContent(t *testing.T, env *adminEnv, id uint, stage, content, contentHTML string) { t.Helper() var stored models.Post if err := env.gdb.First(&stored, id).Error; err != nil { t.Fatal(err) } if strings.Contains(stored.ContentHTML, "raw HTML omitted") { t.Fatalf("%s: content_html holds the goldmark placeholder: %q", stage, stored.ContentHTML) } if stored.Content != content { t.Fatalf("%s: content = %q, want %q", stage, stored.Content, content) } if stored.ContentHTML != contentHTML { t.Fatalf("%s: content_html = %q, want %q", stage, stored.ContentHTML, contentHTML) } } // servedForm returns the record data the admin API serves the form with. func servedForm(t *testing.T, env *adminEnv, token string, id uint) map[string]json.RawMessage { t.Helper() rec := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, id)), token, nil) var data map[string]json.RawMessage if err := json.Unmarshal(adminDecode(t, rec).Data, &data); err != nil { t.Fatalf("show data %s: %v", rec.Body.String(), err) } return data } // servedLocales decodes one served locale map. func servedLocales(t *testing.T, served map[string]json.RawMessage, field string) map[string]string { t.Helper() var out map[string]string if err := json.Unmarshal(served[field], &out); err != nil { t.Fatalf("served %s %s: %v", field, served[field], err) } return out } // servedBody is a save body of the named served fields, unchanged. func servedBody(served map[string]json.RawMessage, fields ...string) map[string]any { out := map[string]any{} for _, field := range fields { if raw, ok := served[field]; ok { out[field] = raw } } return out } // translatedAttrs returns a post's attribute_data in locale, empty when it // has no row. func translatedAttrs(t *testing.T, env *adminEnv, id uint, locale string) map[string]any { t.Helper() var raw *string if err := env.gdb.Table("golem15_translate_attributes"). Select("attribute_data"). Where("model_id = ? AND model_type = ? AND locale = ?", fmt.Sprint(id), models.Post{}.MorphName(), locale). Limit(1). Scan(&raw).Error; err != nil { t.Fatal(err) } out := map[string]any{} if raw == nil || *raw == "" { return out } if err := json.Unmarshal([]byte(*raw), &out); err != nil { t.Fatalf("attribute_data %q: %v", *raw, err) } return out } // translatedRows counts a post's translation rows. func translatedRows(t *testing.T, env *adminEnv, id uint) int64 { t.Helper() var n int64 if err := env.gdb.Table("golem15_translate_attributes"). Where("model_id = ? AND model_type = ?", fmt.Sprint(id), models.Post{}.MorphName()). Count(&n).Error; err != nil { t.Fatal(err) } return n } // setTranslatedAttr sets one key of a post's attribute_data in locale, as an // import does. func setTranslatedAttr(t *testing.T, env *adminEnv, id uint, locale, key, value string) { t.Helper() attrs := translatedAttrs(t, env, id, locale) attrs[key] = value raw, err := json.Marshal(attrs) if err != nil { t.Fatal(err) } res := env.gdb.Table("golem15_translate_attributes"). Where("model_id = ? AND model_type = ? AND locale = ?", fmt.Sprint(id), models.Post{}.MorphName(), locale). Update("attribute_data", string(raw)) if res.Error != nil || res.RowsAffected != 1 { t.Fatalf("set %s %s: rows=%d err=%v", locale, key, res.RowsAffected, res.Error) } } // TestPostsAdminSaveSanitizesHTML pins CR-01, WR-01 and Phase 16.2 D-02 and // D-10 on the admin write path: unsafe markup in content is stripped, as // Winter's Html::clean strips it, instead of refusing the save; content and // content_html both hold the sanitized HTML for the host locale (en) and the // translation (pl); a "Data:" label is ordinary text; and the response's // meta.notices names what was removed for each locale. func TestPostsAdminSaveSanitizesHTML(t *testing.T) { env := newPostsEnv(t) token := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessPublish, controllers.PermissionAccessOtherPosts, ) const content = "

Data: 12 maja 2024

\n" + "

\"a\"onerror=\"alert(1)\"

\n" + "

x

\n" + "" rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), token, map[string]any{ "title": map[string]any{"en": "Sanitized", "pl": "Oczyszczony"}, "slug": map[string]any{"en": "sanitized-html", "pl": "oczyszczony-html"}, "content": map[string]any{"en": content, "pl": content}, }) var created struct { ID uint `json:"id"` } if err := json.Unmarshal(adminDecode(t, rec).Data, &created); err != nil || created.ID == 0 { t.Fatalf("create data %s: %v", rec.Body.String(), err) } var stored models.Post if err := env.gdb.First(&stored, created.ID).Error; err != nil { t.Fatal(err) } const want = "

Data: 12 maja 2024

\n" + "

\"a\"

\n" + "

x

\n" if stored.Content != want || stored.ContentHTML != want { t.Fatalf("host content = %q, content_html = %q, want both %q", stored.Content, stored.ContentHTML, want) } var raw string if err := env.gdb.Table("golem15_translate_attributes"). Select("attribute_data"). Where("model_id = ? AND model_type = ? AND locale = ?", fmt.Sprint(stored.ID), stored.MorphName(), "pl"). Scan(&raw).Error; err != nil { t.Fatal(err) } var pl map[string]any if err := json.Unmarshal([]byte(raw), &pl); err != nil { t.Fatalf("attribute_data %q: %v", raw, err) } if pl["content"] != want || pl["content_html"] != want { t.Fatalf("pl content = %#v, content_html = %#v, want both %q", pl["content"], pl["content_html"], want) } notices := adminNotices(t, rec) for _, locale := range []string{"en", "pl"} { var elements, attributes bool for _, n := range notices { if n.Field != "content" || n.Locale != locale { continue } elements = elements || strings.Contains(n.Message, "2 elements were removed: a, base") attributes = attributes || strings.Contains(n.Message, "1 attribute was removed: onerror") } if !elements || !attributes { t.Fatalf("%s notices = %#v, want removed elements a, base and attribute onerror", locale, notices) } } } // adminNotice is one meta.notices entry of an admin save response. type adminNotice struct { Field string `json:"field"` Locale string `json:"locale"` Message string `json:"message"` } // adminNotices returns a save response's meta.notices, nil when the key is // absent. func adminNotices(t *testing.T, rec *httptest.ResponseRecorder) []adminNotice { t.Helper() var body struct { Meta map[string]json.RawMessage `json:"meta"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("admin body %q: %v", rec.Body.String(), err) } raw, ok := body.Meta["notices"] if !ok { return nil } var notices []adminNotice if err := json.Unmarshal(raw, ¬ices); err != nil { t.Fatalf("meta.notices %s: %v", raw, err) } if notices == nil { notices = []adminNotice{} } return notices }