diff --git a/classes/format_html_test.go b/classes/format_html_test.go
new file mode 100644
index 0000000..2df11da
--- /dev/null
+++ b/classes/format_html_test.go
@@ -0,0 +1,61 @@
+package classes
+
+import (
+ "strings"
+ "testing"
+)
+
+// TestFormatHTMLRejectsUnsafeHTML substitutes JOURNAL-003/004 Twig XSS
+// templates (Phase 16). Script, iframe, event handlers, and javascript /
+// vbscript / data schemes must fail the reject gate. Footnotes and tables
+// from goldmark extensions still pass.
+func TestFormatHTMLRejectsUnsafeHTML(t *testing.T) {
+ html, err := FormatHTML("# Hello")
+ if err != nil || !strings.Contains(html, "
") {
+ t.Fatalf("safe heading: html=%s err=%v", html, err)
+ }
+
+ table, err := FormatHTML("| a | b |\n| - | - |\n| 1 | 2 |")
+ if err != nil {
+ t.Fatalf("table markdown: %v", err)
+ }
+ if !strings.Contains(table, "
alert(1)", "",
+ }, true); err != nil {
+ t.Fatal(err)
+ }
+ if post.Title != "Hello" || post.Slug != "hello" {
+ t.Fatalf("post fill = %+v", post)
+ }
+ if post.RedactorID == nil || *post.RedactorID != 99 {
+ t.Fatalf("redactor_id was filled: %#v", post.RedactorID)
+ }
+ if post.UserID == nil || *post.UserID != 7 {
+ t.Fatalf("user_id was filled: %#v", post.UserID)
+ }
+ if post.ContentHTML != "" {
+ t.Fatalf("content_html was filled: %q", post.ContentHTML)
+ }
+ })
+}
diff --git a/models/translatable_test.go b/models/translatable_test.go
new file mode 100644
index 0000000..e462844
--- /dev/null
+++ b/models/translatable_test.go
@@ -0,0 +1,58 @@
+package models_test
+
+import (
+ "reflect"
+ "testing"
+
+ "git.golem15.com/golem15/sm-journal-plugin/models"
+)
+
+type translatable interface {
+ Translatable() []string
+}
+
+type translatableIndexed interface {
+ TranslatableIndexes() []string
+}
+
+func TestTranslatable(t *testing.T) {
+ post := models.Post{}
+ if post.MorphName() != `Golem15\Journal\Models\Post` {
+ t.Fatalf("Post.MorphName = %q", post.MorphName())
+ }
+ if got := post.Translatable(); !reflect.DeepEqual(got, []string{"title", "content", "content_html", "excerpt", "metadata", "slug"}) {
+ t.Fatalf("Post.Translatable = %#v", got)
+ }
+ if got := post.TranslatableIndexes(); !reflect.DeepEqual(got, []string{"slug"}) {
+ t.Fatalf("Post.TranslatableIndexes = %#v", got)
+ }
+
+ cat := models.Category{}
+ if cat.MorphName() != `Golem15\Journal\Models\Category` {
+ t.Fatalf("Category.MorphName = %q", cat.MorphName())
+ }
+ if got := cat.Translatable(); !reflect.DeepEqual(got, []string{"name", "description", "slug"}) {
+ t.Fatalf("Category.Translatable = %#v", got)
+ }
+ if got := cat.TranslatableIndexes(); !reflect.DeepEqual(got, []string{"slug"}) {
+ t.Fatalf("Category.TranslatableIndexes = %#v", got)
+ }
+
+ tag := models.Tag{}
+ if tag.MorphName() != `Golem15\Journal\Models\Tag` {
+ t.Fatalf("Tag.MorphName = %q", tag.MorphName())
+ }
+ if _, ok := any(tag).(translatable); ok {
+ t.Fatal("Tag must not implement Translatable")
+ }
+ if _, ok := any(tag).(translatableIndexed); ok {
+ t.Fatal("Tag must not implement TranslatableIndexes")
+ }
+}
+
+func TestFilterScopes(t *testing.T) {
+ got := (models.Post{}).FilterScopes()
+ if !reflect.DeepEqual(got, []string{models.FilterPublished, models.FilterCategories}) {
+ t.Fatalf("FilterScopes = %#v", got)
+ }
+}
diff --git a/plugin_test.go b/plugin_test.go
index 0116d7f..b87367c 100644
--- a/plugin_test.go
+++ b/plugin_test.go
@@ -1,6 +1,9 @@
package journal
import (
+ "io/fs"
+ "os"
+ "path/filepath"
"strings"
"testing"
"time"
@@ -77,4 +80,80 @@ func TestJournalBuckets(t *testing.T) {
t.Fatalf("%s Key is nil", name)
}
}
+ hostYAML := filepath.Join("..", "sm-grzybyfunkcjonalne-app", "config", "http.yaml")
+ raw, err := os.ReadFile(hostYAML)
+ if err != nil {
+ return
+ }
+ if !strings.Contains(string(raw), "_journal/api/*") {
+ t.Fatalf("host CORS missing _journal/api/* in %s", hostYAML)
+ }
+}
+
+func TestJournalSVGEmbed(t *testing.T) {
+ p := &Plugin{}
+ fsys := p.AdminFS()
+ f, err := fsys.Open("assets/images/journal-icon.svg")
+ if err != nil {
+ t.Fatalf("AdminFS missing journal-icon.svg: %v", err)
+ }
+ _ = f.Close()
+ filter, err := fs.ReadFile(fsys, "controllers/posts/config_filter.yaml")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(filter), "conditions:") {
+ t.Fatal("config_filter.yaml still has conditions")
+ }
+ fields, err := fs.ReadFile(fsys, "models/post/fields.yaml")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(fields), "type: mlmarkdown") {
+ t.Fatal("post fields.yaml content is not mlmarkdown")
+ }
+}
+
+func TestCabanaMarkdownFieldTypes(t *testing.T) {
+ path := filepath.Join("..", "summercms.go", "modules", "cabana", "form_schema.go")
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read %s: %v", path, err)
+ }
+ src := string(raw)
+ for _, typ := range []string{`"markdown"`, `"mltext"`, `"mlmarkdown"`} {
+ if !strings.Contains(src, typ) {
+ t.Fatalf("form_schema.go missing %s", typ)
+ }
+ }
+}
+
+func TestNoTideJournalFixtures(t *testing.T) {
+ roots := []string{
+ filepath.Join("..", "fonoteka.go", "parity"),
+ filepath.Join("..", "fonoteka.go", "modules", "tide"),
+ filepath.Join("..", "summercms.go", "modules", "tide"),
+ }
+ needle := "/_journal/api/v1"
+ for _, root := range roots {
+ if _, err := os.Stat(root); err != nil {
+ continue
+ }
+ err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
+ if err != nil || d.IsDir() {
+ return err
+ }
+ raw, readErr := os.ReadFile(path)
+ if readErr != nil {
+ return readErr
+ }
+ if strings.Contains(string(raw), needle) {
+ t.Errorf("%s newly mentions %s", path, needle)
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
}
diff --git a/search_test.go b/search_test.go
new file mode 100644
index 0000000..a20405d
--- /dev/null
+++ b/search_test.go
@@ -0,0 +1,64 @@
+package journal_test
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "git.golem15.com/golem15/sm-journal-plugin/models"
+)
+
+// TestSearchGateOff is D-12 / T-15-11. A published save with Typesense
+// configured records zero outbound HTTP because search_use_typesense
+// defaults false. Unpublished ShouldBeSearchable stays false even if the
+// in-memory gate is flipped on.
+func TestSearchGateOff(t *testing.T) {
+ var hits atomic.Int64
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hits.Add(1)
+ w.WriteHeader(http.StatusOK)
+ _, _ = io.WriteString(w, `{}`)
+ }))
+ t.Cleanup(srv.Close)
+ u, err := url.Parse(srv.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ env := newPostsEnv(t,
+ "SUMMER_SEARCH__DRIVER=typesense",
+ "SUMMER_SEARCH__TYPESENSE__API_KEY=test-only-typesense-key",
+ "SUMMER_SEARCH__TYPESENSE__HOST="+u.Hostname(),
+ "SUMMER_SEARCH__TYPESENSE__PORT="+u.Port(),
+ "SUMMER_SEARCH__TYPESENSE__PROTOCOL="+u.Scheme,
+ )
+ past := time.Now().UTC().Add(-time.Hour)
+ post := models.Post{
+ Title: "Published searchable",
+ Slug: "published-searchable",
+ Content: "hello search gate",
+ Published: true,
+ PublishedAt: &past,
+ }
+ if err := env.gdb.Create(&post).Error; err != nil {
+ t.Fatal(err)
+ }
+ if hits.Load() != 0 {
+ t.Fatalf("Typesense HTTP hits = %d, want 0", hits.Load())
+ }
+ if post.ShouldBeSearchable() {
+ t.Fatal("ShouldBeSearchable true with default search_use_typesense")
+ }
+ draft := models.Post{Title: "Draft", Slug: "draft-search", Content: "x", Published: false}
+ models.SetSearchGate(true)
+ t.Cleanup(func() { models.SetSearchGate(false) })
+ if draft.ShouldBeSearchable() {
+ t.Fatal("unpublished ShouldBeSearchable must be false even if the in-memory gate is on")
+ }
+ if hits.Load() != 0 {
+ t.Fatalf("Typesense HTTP hits after in-memory flip = %d, want 0", hits.Load())
+ }
+}
diff --git a/updates/migrations_test.go b/updates/migrations_test.go
new file mode 100644
index 0000000..1d3b59e
--- /dev/null
+++ b/updates/migrations_test.go
@@ -0,0 +1,170 @@
+package updates
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+ "time"
+
+ "git.golem15.com/golem15/summercms/modules/lagoon"
+ "github.com/go-gormigrate/gormigrate/v2"
+ "gorm.io/gorm"
+)
+
+var journalTables = []string{
+ "golem15_journal_posts",
+ "golem15_journal_categories",
+ "golem15_journal_tags",
+ "golem15_journal_posts_categories",
+ "golem15_journal_posts_tags",
+ "golem15_journal_settings",
+}
+
+func TestJournalMigrationsRollbackAndRemigrate(t *testing.T) {
+ sqlDB := dedicatedDB(t, fmt.Sprintf("journal_mig_%d", time.Now().UnixNano()))
+ gdb, err := lagoon.Use(t.Context(), sqlDB)
+ if err != nil {
+ t.Fatal(err)
+ }
+ backend := gormigrate.New(gdb, &gormigrate.Options{
+ TableName: "summer_migrations_summercms_cabana",
+ UseTransaction: true,
+ }, lagoon.BackendAdminMigrations)
+ if err := backend.Migrate(); err != nil {
+ t.Fatalf("backend_users migrations: %v", err)
+ }
+ m := journalMigrator(gdb)
+ if err := m.Migrate(); err != nil {
+ t.Fatalf("migrate: %v", err)
+ }
+ assertJournalSchema(t, gdb)
+
+ for i := 0; i < len(All()); i++ {
+ if err := m.RollbackLast(); err != nil {
+ t.Fatalf("rollback %d: %v", i, err)
+ }
+ }
+ for _, table := range journalTables {
+ if gdb.Migrator().HasTable(table) {
+ t.Fatalf("%s survived rollback", table)
+ }
+ }
+ if gdb.Migrator().HasColumn("backend_users", "golem15_bloghub_author_slug") {
+ t.Fatal("golem15_bloghub_author_slug survived rollback")
+ }
+ assertNoRainlabJournalTables(t, gdb)
+ if err := m.Migrate(); err != nil {
+ t.Fatalf("re-migrate: %v", err)
+ }
+ assertJournalSchema(t, gdb)
+}
+
+func journalMigrator(gdb *gorm.DB) *gormigrate.Gormigrate {
+ return gormigrate.New(gdb, &gormigrate.Options{
+ TableName: "summer_migrations_golem15_journal",
+ UseTransaction: true,
+ }, All())
+}
+
+func assertJournalSchema(t *testing.T, gdb *gorm.DB) {
+ t.Helper()
+ for _, name := range journalTables {
+ if !gdb.Migrator().HasTable(name) {
+ t.Fatalf("missing table %s", name)
+ }
+ }
+ assertNoRainlabJournalTables(t, gdb)
+ if !gdb.Migrator().HasColumn("backend_users", "golem15_bloghub_author_slug") {
+ t.Fatal("backend_users.golem15_bloghub_author_slug missing")
+ }
+
+ wantPosts := []string{
+ "id", "user_id", "redactor_id", "title", "slug", "excerpt", "content",
+ "content_html", "published_at", "published", "is_pinned", "metadata",
+ "sources", "created_at", "updated_at",
+ }
+ for _, col := range wantPosts {
+ if !gdb.Migrator().HasColumn("golem15_journal_posts", col) {
+ t.Fatalf("golem15_journal_posts missing column %s", col)
+ }
+ }
+ assertUdt(t, gdb, "golem15_journal_posts", "metadata", "jsonb")
+ assertUdt(t, gdb, "golem15_journal_posts", "sources", "jsonb")
+ assertIndex(t, gdb, "golem15_journal_posts_slug_unique")
+ assertIndex(t, gdb, "golem15_journal_categories_slug_unique")
+ assertIndex(t, gdb, "golem15_journal_tags_slug_unique")
+
+ var typesense bool
+ if err := gdb.WithContext(t.Context()).
+ Raw(`SELECT search_use_typesense FROM golem15_journal_settings WHERE id = 1`).
+ Scan(&typesense).Error; err != nil {
+ t.Fatal(err)
+ }
+ if typesense {
+ t.Fatal("search_use_typesense default must be false")
+ }
+ var showAll bool
+ if err := gdb.WithContext(t.Context()).
+ Raw(`SELECT show_all_posts FROM golem15_journal_settings WHERE id = 1`).
+ Scan(&showAll).Error; err != nil {
+ t.Fatal(err)
+ }
+ if !showAll {
+ t.Fatal("show_all_posts default must be true")
+ }
+}
+
+func assertNoRainlabJournalTables(t *testing.T, gdb *gorm.DB) {
+ t.Helper()
+ var leftover []string
+ if err := gdb.WithContext(t.Context()).
+ Raw(`SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND (tablename LIKE 'rainlab\_journal\_%' OR tablename LIKE 'winter\_journal\_%')`).
+ Scan(&leftover).Error; err != nil {
+ t.Fatal(err)
+ }
+ if len(leftover) != 0 {
+ t.Fatalf("rainlab/winter-era journal tables present: %v", leftover)
+ }
+}
+
+func assertUdt(t *testing.T, gdb *gorm.DB, table, column, want string) {
+ t.Helper()
+ var udt string
+ if err := gdb.WithContext(t.Context()).Raw(
+ `SELECT udt_name FROM information_schema.columns WHERE table_name = ? AND column_name = ?`,
+ table, column,
+ ).Scan(&udt).Error; err != nil {
+ t.Fatal(err)
+ }
+ if udt != want {
+ t.Fatalf("%s.%s udt = %q, want %q", table, column, udt, want)
+ }
+}
+
+func assertIndex(t *testing.T, gdb *gorm.DB, name string) {
+ t.Helper()
+ var n int64
+ if err := gdb.WithContext(t.Context()).Raw(
+ `SELECT COUNT(*) FROM pg_indexes WHERE schemaname = 'public' AND indexname = ?`,
+ name,
+ ).Scan(&n).Error; err != nil {
+ t.Fatal(err)
+ }
+ if n != 1 {
+ t.Fatalf("index %s count = %d, want 1", name, n)
+ }
+}
+
+func assertUniqueSlug(t *testing.T, gdb *gorm.DB, table, slug string) {
+ t.Helper()
+ if err := gdb.Exec(`INSERT INTO `+table+` (slug) VALUES (?)`, slug).Error; err != nil {
+ t.Fatalf("first insert %s.%s: %v", table, slug, err)
+ }
+ err := gdb.Exec(`INSERT INTO `+table+` (slug) VALUES (?)`, slug).Error
+ if err == nil {
+ t.Fatalf("duplicate slug on %s was accepted", table)
+ }
+ if !strings.Contains(strings.ToLower(err.Error()), "unique") && !strings.Contains(strings.ToLower(err.Error()), "duplicate") {
+ t.Fatalf("duplicate slug error = %v", err)
+ }
+}
diff --git a/updates/postgres_test.go b/updates/postgres_test.go
index 460581b..0ec846c 100644
--- a/updates/postgres_test.go
+++ b/updates/postgres_test.go
@@ -161,41 +161,7 @@ func TestJournalTables(t *testing.T) {
if err := journal.Migrate(); err != nil {
t.Fatalf("journal migrations: %v", err)
}
-
- for _, name := range []string{
- "golem15_journal_posts",
- "golem15_journal_categories",
- "golem15_journal_tags",
- "golem15_journal_posts_categories",
- "golem15_journal_posts_tags",
- "golem15_journal_settings",
- } {
- if !gdb.Migrator().HasTable(name) {
- t.Fatalf("missing table %s", name)
- }
- }
- var leftover []string
- if err := gdb.WithContext(t.Context()).
- Raw(`SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename LIKE 'rainlab\_journal\_%'`).
- Scan(&leftover).Error; err != nil {
- t.Fatal(err)
- }
- if len(leftover) != 0 {
- t.Fatalf("rainlab-era journal tables present: %v", leftover)
- }
- if !gdb.Migrator().HasColumn("backend_users", "golem15_bloghub_author_slug") {
- t.Fatal("backend_users.golem15_bloghub_author_slug missing")
- }
-
- var typesense bool
- if err := gdb.WithContext(t.Context()).
- Raw(`SELECT search_use_typesense FROM golem15_journal_settings WHERE id = 1`).
- Scan(&typesense).Error; err != nil {
- t.Fatal(err)
- }
- if typesense {
- t.Fatal("search_use_typesense default must be false")
- }
+ assertJournalSchema(t, gdb)
var catCount int64
if err := gdb.WithContext(t.Context()).
@@ -216,6 +182,9 @@ func TestJournalTables(t *testing.T) {
if postCount != 0 {
t.Fatalf("seeded posts = %d, want 0", postCount)
}
+ assertUniqueSlug(t, gdb, "golem15_journal_posts", "unique-post-slug")
+ assertUniqueSlug(t, gdb, "golem15_journal_tags", "unique-tag-slug")
+ assertUniqueSlug(t, gdb, "golem15_journal_categories", "unique-cat-slug")
if got := (models.Category{}).Fillable(); !reflect.DeepEqual(got, []string{"name", "slug", "code", "description", "parent_id"}) {
t.Fatalf("Category.Fillable = %#v", got)