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: "
body " + slug + "
", 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 }