Named JOURNAL-001/002 fillable, FormatHTML unsafe tags, rollback/remigrate, RSS/categories/tags/media, and TestSearchGateOff with zero Typesense HTTP. Co-authored-by: Cursor <cursoragent@cursor.com>
192 lines
6.2 KiB
Go
192 lines
6.2 KiB
Go
package journal_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/sm-journal-plugin/controllers"
|
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
|
)
|
|
|
|
var png1x1 = []byte{
|
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
|
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
|
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
|
|
0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, 0x54,
|
|
0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01,
|
|
0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00,
|
|
0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
|
}
|
|
|
|
func (e *adminEnv) callMultipart(t *testing.T, method, path, token string, fields map[string]string, fileField, fileName string, fileBody []byte) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
mw := multipart.NewWriter(&buf)
|
|
for k, v := range fields {
|
|
if err := mw.WriteField(k, v); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if fileField != "" {
|
|
part, err := mw.CreateFormFile(fileField, fileName)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := part.Write(fileBody); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := mw.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req := httptest.NewRequest(method, path, &buf)
|
|
req.Host = "example.com"
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
e.h.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
func TestJournal006MediaUpload(t *testing.T) {
|
|
env := newPostsEnv(t)
|
|
noPerm := env.admin(t)
|
|
denied := env.callMultipart(t, http.MethodPost, "/_journal/api/v1/media/upload", noPerm, nil, "file", "cover.png", png1x1)
|
|
if denied.Code != http.StatusForbidden {
|
|
t.Fatalf("upload without access_posts = %d, want 403: %s", denied.Code, denied.Body.String())
|
|
}
|
|
if got, _ := phpErrorString(t, denied.Body.Bytes()); got != "Insufficient permissions" {
|
|
t.Fatalf("without access_posts error = %q body=%s", got, denied.Body.String())
|
|
}
|
|
|
|
okTok := env.admin(t, controllers.PermissionAccessPosts)
|
|
created := env.callMultipart(t, http.MethodPost, "/_journal/api/v1/media/upload", okTok, nil, "file", "cover.png", png1x1)
|
|
if created.Code != http.StatusCreated {
|
|
t.Fatalf("upload with access_posts = %d, want 201: %s", created.Code, created.Body.String())
|
|
}
|
|
var body struct {
|
|
Data struct {
|
|
URL string `json:"url"`
|
|
Path string `json:"path"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(created.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("upload json: %s err=%v", created.Body.String(), err)
|
|
}
|
|
if !strings.HasPrefix(body.Data.Path, "/journal/") {
|
|
t.Fatalf("path = %q, want prefix /journal/", body.Data.Path)
|
|
}
|
|
|
|
trav := env.callMultipart(t, http.MethodPost, "/_journal/api/v1/media/upload", okTok, map[string]string{"folder": "../etc"}, "file", "cover.png", png1x1)
|
|
if trav.Code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("folder traversal = %d, want 422: %s", trav.Code, trav.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestJournalRSS(t *testing.T) {
|
|
env := newPostsEnv(t)
|
|
title := "Acme Journal"
|
|
if err := env.gdb.Model(&models.Settings{}).Where("id = 1").Updates(map[string]any{
|
|
"rss_title": title,
|
|
"rss_posts_per_feed": 2,
|
|
"rss_include_content": false,
|
|
"rss_enabled": true,
|
|
}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
past := time.Now().UTC().Add(-time.Hour)
|
|
for i, slug := range []string{"one", "two", "three"} {
|
|
p := models.Post{
|
|
Title: "Post " + slug,
|
|
Slug: slug,
|
|
Content: "body " + slug,
|
|
ContentHTML: "<p>body " + slug + "</p>",
|
|
Published: true,
|
|
PublishedAt: ptrTime(past.Add(-time.Duration(i) * time.Minute)),
|
|
}
|
|
if err := env.gdb.Create(&p).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := env.gdb.Create(&models.Post{Title: "Draft", Slug: "draft-rss", Content: "no", Published: false}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
rec := env.call(t, http.MethodGet, "/_journal/api/v1/rss", "", nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET rss = %d, want 200: %s", rec.Code, rec.Body.String())
|
|
}
|
|
ct := rec.Header().Get("Content-Type")
|
|
if !strings.Contains(ct, "application/rss+xml") {
|
|
t.Fatalf("Content-Type = %q, want application/rss+xml", ct)
|
|
}
|
|
type rssXML struct {
|
|
XMLName xml.Name `xml:"rss"`
|
|
Version string `xml:"version,attr"`
|
|
Channel struct {
|
|
Title string `xml:"title"`
|
|
Items []struct {
|
|
Title string `xml:"title"`
|
|
} `xml:"item"`
|
|
} `xml:"channel"`
|
|
}
|
|
var doc rssXML
|
|
if err := xml.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
|
|
t.Fatalf("rss xml: %v body=%s", err, rec.Body.String())
|
|
}
|
|
if doc.Version != "2.0" {
|
|
t.Fatalf("rss version = %q, want 2.0", doc.Version)
|
|
}
|
|
if doc.Channel.Title != title {
|
|
t.Fatalf("channel title = %q, want %q", doc.Channel.Title, title)
|
|
}
|
|
if len(doc.Channel.Items) != 2 {
|
|
t.Fatalf("item count = %d, want 2 (rss_posts_per_feed): %s", len(doc.Channel.Items), rec.Body.String())
|
|
}
|
|
for _, item := range doc.Channel.Items {
|
|
if item.Title == "Draft" {
|
|
t.Fatal("unpublished post appeared in RSS")
|
|
}
|
|
}
|
|
if strings.Contains(rec.Body.String(), "content:encoded") {
|
|
t.Fatal("rss_include_content false still emitted content:encoded")
|
|
}
|
|
|
|
if err := env.gdb.Model(&models.Settings{}).Where("id = 1").Updates(map[string]any{
|
|
"rss_include_content": true,
|
|
}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
withContent := env.call(t, http.MethodGet, "/_journal/api/v1/rss", "", nil)
|
|
if !strings.Contains(withContent.Body.String(), "content:encoded") {
|
|
t.Fatalf("rss_include_content true missing content:encoded: %s", withContent.Body.String())
|
|
}
|
|
|
|
if err := env.gdb.Model(&models.Settings{}).Where("id = 1").Update("rss_enabled", false).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
disabled := env.call(t, http.MethodGet, "/_journal/api/v1/rss", "", nil)
|
|
if disabled.Code != http.StatusOK {
|
|
t.Fatalf("rss_enabled false = %d, want 200", disabled.Code)
|
|
}
|
|
var disabledDoc rssXML
|
|
if err := xml.Unmarshal(disabled.Body.Bytes(), &disabledDoc); err != nil {
|
|
t.Fatalf("rss_enabled false is not well-formed: %v %s", err, disabled.Body.String())
|
|
}
|
|
if disabledDoc.Version != "2.0" || disabledDoc.Channel.Title == "" {
|
|
t.Fatalf("rss_enabled false missing channel: %+v", disabledDoc)
|
|
}
|
|
}
|
|
|
|
func ptrTime(t time.Time) *time.Time { return &t }
|