Initial commit
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Assisted-by: GLM 5.3
This commit is contained in:
@@ -0,0 +1,929 @@
|
||||
// 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user