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), "") && !strings.Contains(stored.ContentHTML, "

") { 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") } }) }