Files
sm-journal-plugin/posts_admin_smoke_test.go
Jakub Zych bdd1c1be61 feat(15-03): add journal media upload, RSS, and off-by-default Typesense gate
- POST /_journal/api/v1/media/upload requires access_posts and stays under journal/
- Anonymous GET rss is RSS 2.0 honoring rss_* settings; unpublished posts are omitted
- beachcomber Gate reads search_use_typesense (default false) so a fresh save makes no Typesense HTTP

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 19:03:35 +02:00

393 lines
11 KiB
Go

package journal_test
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
_ "git.golem15.com/golem15/sm-journal-plugin"
"git.golem15.com/golem15/sm-journal-plugin/controllers"
"git.golem15.com/golem15/sm-journal-plugin/models"
_ "git.golem15.com/golem15/sm-translate-plugin"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/surf"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"gocloud.dev/blob/memblob"
"gorm.io/gorm"
)
const (
adminPostsPath = "/golem15/journal/posts"
adminJWTSecret = "journal-admin-test-only-hs256-secret-key"
)
var (
adminPG *postgres.PostgresContainer
adminSQL *sql.DB
adminDSN string
adminPGErr error
adminSeq atomic.Uint64
)
func TestMain(m *testing.M) {
code := 1
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
adminPGErr = startAdminPostgres(ctx)
cancel()
if adminPGErr != nil {
fmt.Fprintf(os.Stderr, "golem15.journal: testcontainers postgres: %v\n", adminPGErr)
stopAdminPostgres()
os.Exit(1)
}
code = m.Run()
stopAdminPostgres()
os.Exit(code)
}
func startAdminPostgres(ctx context.Context) error {
ctr, err := postgres.Run(ctx,
"postgres:16-alpine",
postgres.WithDatabase("golem15_journal_admin"),
postgres.WithUsername("golem15_journal_admin"),
postgres.WithPassword("golem15_journal_admin"),
postgres.BasicWaitStrategies(),
)
if err != nil {
return err
}
adminPG = ctr
dsn, err := ctr.ConnectionString(ctx, "sslmode=disable")
if err != nil {
return err
}
db, err := sql.Open("pgx", dsn)
if err != nil {
return err
}
if err := db.PingContext(ctx); err != nil {
_ = db.Close()
return err
}
adminSQL = db
adminDSN = dsn
return nil
}
func stopAdminPostgres() {
if adminSQL != nil {
_ = adminSQL.Close()
}
if adminPG != nil {
_ = testcontainers.TerminateContainer(adminPG)
}
}
func requirePostgres(t *testing.T) *sql.DB {
t.Helper()
if adminPGErr != nil {
t.Fatalf("postgres unavailable: %v", adminPGErr)
}
if adminSQL == nil {
t.Fatal("postgres unavailable: container was not started")
}
return adminSQL
}
func dedicatedDB(t *testing.T, name string) *sql.DB {
t.Helper()
admin := requirePostgres(t)
ctx := t.Context()
quoted := `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
if _, err := admin.ExecContext(ctx, `CREATE DATABASE `+quoted+` TEMPLATE template0 ENCODING 'UTF8'`); err != nil && !strings.Contains(err.Error(), "already exists") {
t.Fatalf("create %s: %v", name, err)
}
u, err := url.Parse(adminDSN)
if err != nil {
t.Fatal(err)
}
u.Path = "/" + name
db, err := sql.Open("pgx", u.String())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = db.Close()
_, _ = admin.ExecContext(context.Background(), `DROP DATABASE IF EXISTS `+quoted+` WITH (FORCE)`)
})
if err := db.PingContext(ctx); err != nil {
t.Fatal(err)
}
return db
}
type adminEnv struct {
h http.Handler
app *backpack.App
gdb *gorm.DB
}
func adminAPI(rel string) string {
return cabana.DefaultAdminPrefix + "/api/v1" + rel
}
func newPostsEnv(t *testing.T, extraEnv ...string) *adminEnv {
t.Helper()
sqlDB := dedicatedDB(t, "journal_admin_"+strconv.FormatInt(time.Now().UnixNano(), 10))
gdb, err := lagoon.Use(t.Context(), sqlDB)
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: journal-admin\nlocale: en\nfallback_locale: en\nurl: https://app.example.test\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{
Dir: dir,
Environ: append([]string{
"SUMMER_ENV=development",
"SUMMER_ADMIN__JWT__SECRET=" + adminJWTSecret,
}, extraEnv...),
})
if err != nil {
t.Fatal(err)
}
for key, value := range map[string]any{
"http.body_limits.default_bytes": 1048576,
"http.body_limits.upload_bytes": 8388608,
} {
if err := cfg.Set(key, value); err != nil {
t.Fatal(err)
}
}
app := backpack.New(cfg)
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
t.Fatal(err)
}
bucket := memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
if err := attach.Publish(app, bucket); err != nil {
t.Fatal(err)
}
plugins, err := party.Activate(app, []string{"golem15.translate", "golem15.journal"})
if err != nil {
t.Fatal(err)
}
if err := lagoon.Migrate(gdb, plugins); err != nil {
t.Fatal(err)
}
h, err := surf.Assemble(app, plugins)
if err != nil {
t.Fatal(err)
}
if err := gdb.Exec(`INSERT INTO golem15_translate_locales (code, name, is_default, is_enabled, sort_order) VALUES
('en', 'English', TRUE, TRUE, 1),
('pl', 'Polski', FALSE, TRUE, 2)
ON CONFLICT DO NOTHING`).Error; err != nil {
t.Fatal(err)
}
return &adminEnv{h: h, app: app, gdb: gdb}
}
func (e *adminEnv) admin(t *testing.T, permissions ...string) string {
t.Helper()
stamp := fmt.Sprintf("%d%d", time.Now().UnixNano()%1_000_000_000, adminSeq.Add(1))
login := "admin-" + stamp
password := "admin-secret-" + stamp
hash, err := bouncer.HashPassword(10, password)
if err != nil {
t.Fatal(err)
}
grants := map[string]int{}
for _, code := range permissions {
grants[code] = 1
}
raw, err := json.Marshal(grants)
if err != nil {
t.Fatal(err)
}
var roleID uint
if err := e.gdb.Raw(`INSERT INTO backend_user_roles (name, code, permissions, is_system, created_at, updated_at) VALUES (?, ?, ?, FALSE, NOW(), NOW()) RETURNING id`,
"Role "+stamp, "role-"+stamp, string(raw)).Scan(&roleID).Error; err != nil || roleID == 0 {
t.Fatalf("backend role: id=%d err=%v", roleID, err)
}
if err := e.gdb.Exec(`INSERT INTO backend_users (login, email, password, is_activated, is_superuser, role_id, created_at, updated_at) VALUES (?, ?, ?, TRUE, FALSE, ?, NOW(), NOW())`,
login, login+"@example.test", hash, roleID).Error; err != nil {
t.Fatal(err)
}
rec := e.call(t, http.MethodPost, adminAPI("/auth/login"), "", map[string]string{"login": login, "password": password})
if rec.Code != http.StatusOK {
t.Fatalf("admin login %s: %d %s", login, rec.Code, rec.Body.String())
}
var body struct {
Data struct {
AccessToken string `json:"access_token"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.AccessToken == "" {
t.Fatalf("admin login body: %s err=%v", rec.Body.String(), err)
}
return body.Data.AccessToken
}
func (e *adminEnv) call(t *testing.T, method, path, token string, body any) *httptest.ResponseRecorder {
t.Helper()
var raw []byte
if body != nil {
var err error
if raw, err = json.Marshal(body); err != nil {
t.Fatal(err)
}
}
var reader io.Reader
if raw != nil {
reader = bytes.NewReader(raw)
}
req := httptest.NewRequest(method, path, reader)
req.Host = "example.com"
req.RemoteAddr = fmt.Sprintf("198.51.100.%d:4000", adminSeq.Add(1)%250+1)
req.Header.Set("Accept-Language", "en")
if raw != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, req)
return rec
}
func (e *adminEnv) expect(t *testing.T, status int, method, path, token string, body any) *httptest.ResponseRecorder {
t.Helper()
rec := e.call(t, method, path, token, body)
if rec.Code != status {
t.Fatalf("%s %s = %d, want %d: %s", method, path, rec.Code, status, rec.Body.String())
}
return rec
}
type adminEnvelope struct {
Data json.RawMessage `json:"data"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func adminDecode(t *testing.T, rec *httptest.ResponseRecorder) adminEnvelope {
t.Helper()
var env adminEnvelope
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
t.Fatalf("admin body %q: %v", rec.Body.String(), err)
}
return env
}
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, "mlmarkdown") {
t.Fatalf("form schema missing mlmarkdown: %s", body)
}
if !strings.Contains(body, "mltext") {
t.Fatalf("form schema missing mltext: %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"},
})
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)
}
if stored.ContentHTML == "" {
t.Fatal("content_html is empty")
}
if strings.Contains(strings.ToLower(stored.ContentHTML), "<script") {
t.Fatalf("content_html contains script: %s", stored.ContentHTML)
}
if !strings.Contains(stored.ContentHTML, "<strong>") && !strings.Contains(stored.ContentHTML, "<p>") {
t.Fatalf("content_html missing markup: %s", 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")
}
})
}