// Copyright (c) 2026 Petr Balvín (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, `
bold") { 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(), "`, `[1]`, `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()) } } }