Files
volumen/internal/admin/posts_routes_test.go
T

930 lines
30 KiB
Go
Raw Normal View History

2026-09-18 12:03:35 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"bytes"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"sourcedock.dev/petrbalvin/volumen/internal/biblio"
"sourcedock.dev/petrbalvin/volumen/internal/post"
"sourcedock.dev/petrbalvin/volumen/internal/preview"
)
func writeTestPost(t *testing.T, f *fixture, name, body string) {
t.Helper()
path := filepath.Join(f.contentDir, name)
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("write post: %v", err)
}
}
const samplePost = `+++
title = "Hello"
slug = "hello"
date = 2026-08-18
lang = "cs"
tags = ["go", "blog"]
+++
Hello **body**.
`
func TestDashboardRenders(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
"Posts", "Hello", `data-slug="hello"`, "Published", "Drafts",
`data-tag="go"`, "1 post", "Log out",
} {
if !strings.Contains(body, want) {
t.Fatalf("missing %q", want)
}
}
}
func TestDashboardGroupsLanguageVariants(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", `+++
title = "Hello"
slug = "hello"
date = 2026-08-18
lang = "en"
translations = { cs = "ahoj" }
+++
English body.
`)
writeTestPost(t, f, "ahoj.md", `+++
title = "Ahoj"
slug = "ahoj"
date = 2026-08-18
lang = "cs"
translations = { en = "hello" }
+++
České tělo.
`)
writeTestPost(t, f, "lonely.md", `+++
title = "Lonely"
slug = "lonely"
date = 2026-08-17
lang = "en"
+++
Alone.
`)
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
req.AddCookie(cookie)
body := f.do(t, req).Body.String()
// The linked pair is one card, the unrelated post the second one.
if got := strings.Count(body, `<article class="post"`); got != 2 {
t.Fatalf("cards = %d, want 2", got)
}
if got := strings.Count(body, `data-variants=`); got != 1 {
t.Fatalf("cards with variants = %d, want 1", got)
}
if !strings.Contains(body, `data-slug="hello"`) || strings.Contains(body, `data-slug="ahoj"`) {
t.Fatal("the variant ahoj must not stand as its own card")
}
// Both language versions are reachable from the switch chips.
if !strings.Contains(body, `data-lang="en"`) || !strings.Contains(body, `data-lang="cs"`) {
t.Fatal("the card must offer both language variants")
}
// The selection acts on the whole publication: both slugs ride along.
if !strings.Contains(body, `value="hello,ahoj"`) && !strings.Contains(body, `value="ahoj,hello"`) {
t.Fatal("the bulk selection must carry every variant slug")
}
}
func TestDashboardRequiresLogin(t *testing.T) {
f := newFixture(t)
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/", nil))
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/login" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
}
func TestNewFormRendersEditor(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/posts/new", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{"New post", `id="post-form"`, `action="/admin/posts"`, "Markdown"} {
if !strings.Contains(body, want) {
t.Fatalf("missing %q", want)
}
}
}
func TestEditFormRendersPost(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/posts/hello/edit", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
"Edit post", `value="Hello"`, `value="hello"`, `readonly`,
`action="/admin/posts/hello"`, "Hello **body**.",
} {
if !strings.Contains(body, want) {
t.Fatalf("missing %q", want)
}
}
req = httptest.NewRequest(http.MethodGet, "/admin/posts/ghost/edit", nil)
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusNotFound {
t.Fatalf("code = %d", rec.Code)
}
}
func TestCreatePostViaForm(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
form := url.Values{
"_csrf": {csrf},
"title": {"Fresh"},
"slug": {"fresh"},
"lang": {"cs"},
"tags": {"a, b"},
"body": {"content"},
"draft": {"on"},
"author": {"Petr"},
}
rec := postForm(t, f, "/admin/posts", form, cookie)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/?saved=created" {
t.Fatalf("code=%d location=%q body=%s", rec.Code, rec.Header().Get("Location"), rec.Body.String())
}
if p := f.findPost("fresh"); p == nil || p.Title() != "Fresh" || !p.Draft() {
t.Fatalf("post = %v", p)
}
if len(f.events) == 0 || f.events[len(f.events)-1] != "post.created" {
t.Fatalf("events = %v", f.events)
}
}
func TestCreatePostValidationRendersForm(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
form := url.Values{"_csrf": {csrf}, "title": {"X"}, "slug": {"Bad Slug"}, "body": {"b"}}
rec := postForm(t, f, "/admin/posts", form, cookie)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("code = %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Invalid slug.") {
t.Fatal("validation message missing")
}
}
func TestUpdatePostKeepsPath(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
form := url.Values{
"_csrf": {csrf}, "title": {"Updated"}, "slug": {"hello"},
"lang": {"cs"}, "tags": {"go"}, "body": {"new body"},
}
rec := postForm(t, f, "/admin/posts/hello", form, cookie)
if rec.Code != http.StatusSeeOther {
t.Fatalf("code = %d", rec.Code)
}
p := f.findPost("hello")
if p == nil || p.Title() != "Updated" || p.Body != "new body\n" {
t.Fatalf("post = %v", p)
}
}
// The bibliography card writes the refs tables through the whole save
// path: the editor's JSON reaches the file as [[refs]] blocks, an
// author's ORCID survives as a name table, and a frontmatter key the
// form never names round-trips untouched beside them.
func TestEditorSavesTheBibliography(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", `+++
title = "Cite"
slug = "hello"
date = 2026-08-18
note = "keep me"
tags = ["go"]
[[refs]]
raw = "Old entry."
+++
Cites [1].
`)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
// The edit form hands the stored list to the card's script, and the
// card sits below the editor with its own bounded list.
req := httptest.NewRequest(http.MethodGet, "/admin/posts/hello/edit", nil)
req.AddCookie(cookie)
body := f.do(t, req).Body.String()
for _, want := range []string{`id="refs-card"`, "Old entry.", `id="ref-row-template"`, `id="refs-count"`, `class="refs-scroll"`} {
if !strings.Contains(body, want) {
t.Fatalf("edit form missing %q", want)
}
}
if strings.Index(body, `id="refs-card"`) < strings.Index(body, `id="post-form"`) {
t.Fatal("the bibliography card must not precede the form")
}
editorSection := strings.Index(body, `id="markdown-view"`)
refsCard := strings.Index(body, `id="refs-card"`)
if editorSection == -1 || refsCard == -1 || refsCard < editorSection {
t.Fatal("the bibliography card must sit below the editor")
}
form := url.Values{
"_csrf": {csrf}, "title": {"Cite"}, "slug": {"hello"}, "body": {"Cites [1].\n\n[[refs]]\n"},
"refs": {`[` +
`{"num":2,"raw":"Kept and edited."},` +
`{"raw":"Added verbatim.","doi":"10.1086/300499"},` +
`{"authors":[{"name":"Adam Riess","orcid":"0000-0002-1825-0097"}],` +
`"title":"Observational Evidence","year":"1998"}` +
`]`},
}
rec := postForm(t, f, "/admin/posts/hello", form, cookie)
if rec.Code != http.StatusSeeOther {
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
}
raw, err := os.ReadFile(filepath.Join(f.contentDir, "hello.md"))
if err != nil {
t.Fatalf("read post: %v", err)
}
file := string(raw)
for _, want := range []string{
`note = "keep me"`,
"[[refs]]",
"raw = \"Kept and edited.\"",
"num = 2",
"raw = \"Added verbatim.\"",
`doi = "10.1086/300499"`,
`title = "Observational Evidence"`,
`{name = "Adam Riess", orcid = "0000-0002-1825-0097"}`,
} {
if !strings.Contains(file, want) {
t.Fatalf("saved file missing %q:\n%s", want, file)
}
}
if strings.Contains(file, "Old entry.") {
t.Fatalf("the replaced entry survived:\n%s", file)
}
// The rewritten list renders with its citations linked.
p := f.findPost("hello")
refs := p.RefsLinked()
if len(refs) != 3 {
t.Fatalf("refs = %v", refs)
}
if refs[0].Num != 2 || refs[0].Raw != "Kept and edited." {
t.Fatalf("first entry = %+v", refs[0])
}
html, err := p.HTML()
if err != nil {
t.Fatalf("render: %v", err)
}
// The preserved numbers name the anchors: the first entry keeps its
// explicit num = 2, so the list carries ref-2 and ref-3 and no ref-1.
if !strings.Contains(html, `id="ref-2"`) || !strings.Contains(html, `id="ref-3"`) {
t.Fatalf("rendered list wrong:\n%s", html)
}
if strings.Contains(html, `id="ref-1"`) {
t.Fatalf("an unnumbered anchor appeared:\n%s", html)
}
}
// A save whose form carries no refs field keeps the stored list, so the
// bibliography never disappears under a page that does not edit it.
func TestEditorWithoutRefsKeepsTheStoredList(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", `+++
title = "Cite"
slug = "hello"
date = 2026-08-18
[[refs]]
raw = "Old entry."
+++
Body.
`)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
form := url.Values{
"_csrf": {csrf}, "title": {"Cite"}, "slug": {"hello"}, "body": {"Body.\n"},
}
if rec := postForm(t, f, "/admin/posts/hello", form, cookie); rec.Code != http.StatusSeeOther {
t.Fatalf("code = %d", rec.Code)
}
if refs := f.findPost("hello").Refs(); len(refs) != 1 || refs[0].Raw != "Old entry." {
t.Fatalf("refs = %v", refs)
}
}
func TestDeleteAndUndeleteFlow(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/posts/hello/delete", url.Values{"_csrf": {csrf}}, cookie)
if rec.Code != http.StatusSeeOther ||
!strings.Contains(rec.Header().Get("Location"), "saved=deleted&undo=hello") {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
if f.findPost("hello") != nil {
t.Fatal("post still present")
}
// The webhook contract names the post under the "post" key, the same
// shape every other post event and the API's delete endpoint deliver.
last := len(f.events) - 1
if last < 0 || f.events[last] != "post.deleted" {
t.Fatalf("events = %v", f.events)
}
inner, ok := f.payloads[last]["post"].(map[string]any)
if !ok || inner["slug"] != "hello" || inner["title"] != "Hello" {
t.Fatalf("post.deleted payload = %#v", f.payloads[last])
}
rec = postForm(t, f, "/admin/posts/hello/undelete", url.Values{"_csrf": {csrf}}, cookie)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/?saved=undone" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
if f.findPost("hello") == nil {
t.Fatal("post not restored")
}
}
// A bulk delete delivers the same post.deleted shape as the single
// delete, so a subscriber cannot tell which screen the change came from.
func TestBulkDeleteEventShape(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/posts/bulk",
url.Values{"_csrf": {csrf}, "action": {"delete"}, "slugs": {"hello"}}, cookie)
if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "n=1") {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
last := len(f.events) - 1
if last < 0 || f.events[last] != "post.deleted" {
t.Fatalf("events = %v", f.events)
}
inner, ok := f.payloads[last]["post"].(map[string]any)
if !ok || inner["slug"] != "hello" {
t.Fatalf("post.deleted payload = %#v", f.payloads[last])
}
}
func TestDuplicateCreatesDraftCopy(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/posts/hello/duplicate", url.Values{"_csrf": {csrf}}, cookie)
if rec.Code != http.StatusSeeOther ||
!strings.Contains(rec.Header().Get("Location"), "/admin/posts/hello-copy/edit") {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
copyPost := f.findPost("hello-copy")
if copyPost == nil || !copyPost.Draft() {
t.Fatalf("copy = %v", copyPost)
}
if copyPost.DateString() != "" {
t.Fatalf("copy kept the date: %q", copyPost.DateString())
}
// A second duplicate gets the -copy-2 suffix.
rec = postForm(t, f, "/admin/posts/hello/duplicate", url.Values{"_csrf": {csrf}}, cookie)
if !strings.Contains(rec.Header().Get("Location"), "hello-copy-2") {
t.Fatalf("location = %q", rec.Header().Get("Location"))
}
}
// A duplicate that cannot be created must say so on the dashboard, not
// disappear behind a silent redirect.
func TestDuplicateFailureIsReported(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", `+++
title = "Hello"
slug = "hello"
date = 2026-08-18
doi = "not-a-doi"
+++
Body.
`)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/posts/hello/duplicate", url.Values{"_csrf": {csrf}}, cookie)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/?saved=duplicate_failed" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
if f.findPost("hello-copy") != nil {
t.Fatal("a rejected duplicate must not be saved")
}
}
// The editor's API link carries a valid preview token, so it opens a
// draft as well as a published post.
func TestEditFormCarriesPreviewToken(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/posts/hello/edit", nil)
req.AddCookie(cookie)
body := f.do(t, req).Body.String()
mark := `/api/volumen/posts/hello?preview_token=`
i := strings.Index(body, mark)
if i < 0 {
t.Fatal("API link without a preview token")
}
token, _, _ := strings.Cut(body[i+len(mark):], `"`)
if !preview.Valid(token, "hello", f.admin.deps.PreviewKey, time.Now()) {
t.Fatalf("preview token invalid: %q", token)
}
}
func TestBulkActions(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "a.md", "+++\nslug = \"a\"\ntitle = \"A\"\ndraft = true\n+++\nx\n")
writeTestPost(t, f, "b.md", "+++\nslug = \"b\"\ntitle = \"B\"\n+++\nx\n")
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/posts/bulk",
url.Values{"_csrf": {csrf}, "action": {"publish"}, "slugs": {"a"}}, cookie)
if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "bulk=publish&n=1") {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
if f.findPost("a").Draft() {
t.Fatal("post not published")
}
rec = postForm(t, f, "/admin/posts/bulk",
url.Values{"_csrf": {csrf}, "action": {"delete"}, "slugs": {"a,b"}}, cookie)
if !strings.Contains(rec.Header().Get("Location"), "n=2") {
t.Fatalf("location = %q", rec.Header().Get("Location"))
}
if f.findPost("a") != nil || f.findPost("b") != nil {
t.Fatal("posts not deleted")
}
}
func TestSlugExistsEndpoint(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
for path, want := range map[string]string{
"/admin/posts/exists?slug=hello": `"available":false`,
"/admin/posts/exists?slug=fresh": `"available":true`,
"/admin/posts/exists?slug=": `"available":true`,
"/admin/posts/exists?slug=hello&exclude=hello": `"available":true`,
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), want) {
t.Fatalf("%s: code=%d body=%s", path, rec.Code, rec.Body.String())
}
}
}
func TestPreviewEndpoint(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/preview", url.Values{"_csrf": {csrf}, "body": {"**bold**"}}, cookie)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "<strong>bold</strong>") {
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestPreviewLinkEndpoint(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "draft.md", "+++\nslug = \"d\"\ndraft = true\n+++\nx\n")
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/posts/d/preview-link", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "preview_token=") {
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestImportFlow(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := multipartForm(t, f, "/admin/posts/import", cookie, csrf,
"file", "imported.md", "+++\ntitle = \"Imported\"\nslug = \"imported\"\n+++\nbody\n")
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/posts/imported/edit" {
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
}
if f.findPost("imported") == nil {
t.Fatal("import not saved")
}
// Non-.md rejected.
rec = multipartForm(t, f, "/admin/posts/import", cookie, csrf,
"file", "evil.txt", "content")
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("code = %d", rec.Code)
}
// Import without a slug derives one from the file name.
rec = multipartForm(t, f, "/admin/posts/import", cookie, csrf,
"file", "derived-slug.md", "Just a body, no frontmatter.\n")
if rec.Code != http.StatusSeeOther {
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
}
if f.findPost("derived-slug") == nil {
t.Fatal("derived slug import failed")
}
}
func TestDownloadAndHistory(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
// Saving twice archives one revision.
form := url.Values{
"_csrf": {csrf}, "title": {"Hello"}, "slug": {"hello"},
"lang": {"cs"}, "body": {"changed"},
}
postForm(t, f, "/admin/posts/hello", form, cookie)
req := httptest.NewRequest(http.MethodGet, "/admin/posts/hello/download", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "title = \"Hello\"") {
t.Fatalf("download code=%d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Header().Get("Content-Disposition"), `filename="hello.md"`) {
t.Fatalf("disposition = %q", rec.Header().Get("Content-Disposition"))
}
req = httptest.NewRequest(http.MethodGet, "/admin/posts/hello/history", nil)
req.AddCookie(cookie)
rec = f.do(t, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "History") {
t.Fatalf("history code=%d", rec.Code)
}
if !strings.Contains(rec.Body.String(), " kB") {
t.Fatal("revision size missing")
}
}
func TestUploadRejectsGarbage(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := multipartForm(t, f, "/admin/uploads", cookie, csrf,
"file", "x.webp", "not an image at all")
if rec.Code != http.StatusUnsupportedMediaType {
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
}
webpData := append([]byte("RIFF"), 0, 0, 0, 0)
webpData = append(webpData, []byte("WEBPVP8 ")...)
rec = multipartForm(t, f, "/admin/uploads", cookie, csrf,
"file", "pic.png", string(webpData))
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "/media/") {
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestCSRFRequiredOnMutations(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
for _, path := range []string{
"/admin/posts", "/admin/posts/bulk", "/admin/preview",
"/admin/posts/import",
} {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader("_csrf=wrong"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusForbidden {
t.Fatalf("%s: code = %d, want 403", path, rec.Code)
}
}
}
// --- helpers ---------------------------------------------------------------
func (f *fixture) findPost(slug string) *post.Post {
return f.storeObj.Find(slug, "")
}
// csrfFromSession performs the login GET flow and returns the CSRF token.
func csrfFromSession(t *testing.T, f *fixture, cookie *http.Cookie) string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/admin/login", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code == http.StatusOK {
return extractCSRF(t, rec.Body.String())
}
// Authenticated: pull the token from the session instead.
sess := f.store.Load(req)
return CSRFToken(sess)
}
func postForm(t *testing.T, f *fixture, path string, form url.Values, cookie *http.Cookie) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
return f.do(t, req)
}
func multipartForm(t *testing.T, f *fixture, path string, cookie *http.Cookie, csrf, field, filename, content string) *httptest.ResponseRecorder {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
_ = mw.WriteField("_csrf", csrf)
part, err := mw.CreateFormFile(field, filename)
if err != nil {
t.Fatalf("create form file: %v", err)
}
if _, err := part.Write([]byte(content)); err != nil {
t.Fatalf("write part: %v", err)
}
_ = mw.Close()
req := httptest.NewRequest(http.MethodPost, path, &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.AddCookie(cookie)
return f.do(t, req)
}
// The history page's two per-revision endpoints are the ones an operator
// reaches for after a bad edit, so both are exercised: the download and
// the restore, including the redirect that carries the flash message.
func TestHistoryDownloadAndRestore(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
// One save archives the original.
postForm(t, f, "/admin/posts/hello", url.Values{
"_csrf": {csrf}, "title": {"Hello"}, "slug": {"hello"},
"lang": {"cs"}, "body": {"changed"},
}, cookie)
revisions := f.admin.deps.Store.Revisions("hello")
if len(revisions) != 1 {
t.Fatalf("revisions = %v", revisions)
}
name := revisions[0].Name
req := httptest.NewRequest(http.MethodGet, "/admin/posts/hello/history/"+name, nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("history download code = %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Hello **body**.") {
t.Fatalf("revision body = %s", rec.Body.String())
}
if got := rec.Header().Get("Content-Disposition"); !strings.Contains(got, "hello-"+name) {
t.Fatalf("disposition = %q", got)
}
// An unknown revision name is a 404, not an empty file.
req = httptest.NewRequest(http.MethodGet, "/admin/posts/hello/history/nope.md", nil)
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusNotFound {
t.Fatalf("unknown revision code = %d", rec.Code)
}
// Restoring puts the archived body back and redirects to the editor.
req = httptest.NewRequest(http.MethodPost, "/admin/posts/hello/history/"+name+"/restore",
strings.NewReader("_csrf="+url.QueryEscape(csrf)))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec = f.do(t, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("restore code = %d body=%s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Location"); got != "/admin/posts/hello/edit?restored=1" {
t.Fatalf("location = %q", got)
}
restored := f.storeObj.Find("hello", "")
if restored == nil || !strings.Contains(restored.Body, "Hello **body**.") {
t.Fatalf("body after restore = %q", restored.Body)
}
}
// The brand SVG loads on every admin page, so a broken embed pattern
// would break the whole UI silently. The one asset is the icon: the
// favicon, the login brand and the topbar badge all read the same file.
func TestStaticSVGRoutes(t *testing.T) {
f := newFixture(t)
const path = "/admin/icon.svg"
rec := f.do(t, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusOK {
t.Fatalf("%s: code = %d", path, rec.Code)
}
if got := rec.Header().Get("Content-Type"); got != "image/svg+xml" {
t.Fatalf("%s: content-type = %q", path, got)
}
if !strings.Contains(rec.Body.String(), "<svg") {
t.Fatalf("%s: body is not an SVG", path)
}
}
func TestImportFormRenders(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/posts/import", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `name="file"`) {
t.Fatalf("import form code=%d body=%s", rec.Code, rec.Body.String())
}
}
// Deleting a media file removes it from the library and from the public
// route, and a second delete reports the absence.
func TestMediaDelete(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := multipartForm(t, f, "/admin/uploads", cookie, csrf, "file", "x.webp", string(webpFixture()))
if rec.Code != http.StatusOK {
t.Fatalf("upload code = %d body=%s", rec.Code, rec.Body.String())
}
items := f.storeObj.ListMedia()
if len(items) != 1 {
t.Fatalf("media = %v", items)
}
name := items[0].Name
req := httptest.NewRequest(http.MethodPost, "/admin/media/"+name+"/delete",
strings.NewReader("_csrf="+url.QueryEscape(csrf)))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusSeeOther {
t.Fatalf("delete code = %d", rec.Code)
}
if len(f.storeObj.ListMedia()) != 0 {
t.Fatal("media survived the delete")
}
req = httptest.NewRequest(http.MethodPost, "/admin/media/"+name+"/delete",
strings.NewReader("_csrf="+url.QueryEscape(csrf)))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusNotFound {
t.Fatalf("second delete code = %d, want 404", rec.Code)
}
}
// webpFixture is a minimal RIFF/WEBP header, enough for the signature
// check the upload path performs.
func webpFixture() []byte {
data := append([]byte("RIFF"), 0, 0, 0, 0)
return append(data, []byte("WEBPVP8 ")...)
}
// A malformed date in the editor form is rejected with the post
// re-rendered, rather than silently dropping an inherited schedule.
func TestEditorRejectsMalformedPublishAt(t *testing.T) {
f := newFixture(t)
if err := os.WriteFile(filepath.Join(f.contentDir, "sched.md"),
[]byte("+++\ntitle = \"S\"\nslug = \"sched\"\npublish_at = 2999-01-01\n+++\nbody\n"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/posts/sched", url.Values{
"_csrf": {csrf}, "title": {"S"}, "slug": {"sched"},
"publish_at": {"not a date"}, "body": {"body"},
}, cookie)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "publish_at must be an ISO 8601 date") {
t.Fatal("validation message missing")
}
if p := f.admin.deps.Store.Find("sched", ""); p == nil || !p.Scheduled() {
t.Fatal("the stored schedule was dropped by the rejected save")
}
}
// An admin POST body over the configured allowance is cut off with 413
// before it can fill the temp directory.
func TestAdminBodyOverTheLimitIs413(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
huge := strings.Repeat("a", 12*1024*1024)
rec := postForm(t, f, "/admin/posts", url.Values{
"_csrf": {csrf}, "title": {"Big"}, "slug": {"big"}, "body": {huge},
}, cookie)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("code = %d, want 413", rec.Code)
}
}
// The editor preview must show the bibliography the published page
// will show: the refs live in the saved post's frontmatter, which the
// body-only render cannot see on its own.
func TestPreviewWeavesSavedRefs(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "cite.md", `+++
title = "Cite"
slug = "cite"
[[refs]]
title = "Observational evidence from supernovae"
doi = "10.1103/PhysRevD.59.103502"
+++
Tvrzení [1].
[[refs]]
`)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/preview", url.Values{
"_csrf": {csrf}, "body": {"Tvrzení [1].\n\n[[refs]]\n"},
"slug": {"cite"},
}, cookie)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
`<section class="refs" id="references">`,
`<a class="ref-cite" href="#ref-1">[1]</a>`,
`href="https://doi.org/10.1103/PhysRevD.59.103502"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("preview missing %q:\n%s", want, body)
}
}
// A new post with no saved refs keeps the marker as inert text, and
// an unknown slug is just the plain body render.
for _, slug := range []string{"", "ghost"} {
rec := postForm(t, f, "/admin/preview", url.Values{
"_csrf": {csrf}, "body": {"[[refs]]\n"}, "slug": {slug},
}, cookie)
if !strings.Contains(rec.Body.String(), biblio.Marker) {
t.Fatalf("slug %q: preview rewrote an unsaved marker:\n%s", slug, rec.Body.String())
}
}
}