Files
photog/manage_test.go
T
puttaalu bf75c9fe8e Fix manage handlers ignoring multipart form bodies
The /manage page posts with FormData, i.e. multipart/form-data, but the edit,
delete, tags, restore and empty-trash handlers parsed with r.ParseForm, which
only reads urlencoded bodies. So every field came back empty: delete returned
200 with deleted:0 and moved nothing, edits saved nothing — while curl -d
(urlencoded) worked, which is why tests and manual checks missed it.

Parse with ParseMultipartForm (tolerating ErrNotMultipart so urlencoded still
works), via a shared parseForm helper used by all manage handlers. handleUpload
already did this. Also stop the UI reporting deleted:0 / updated:0 as success,
and add a multipart regression test.
2026-10-18 22:32:18 +02:00

333 lines
10 KiB
Go

package main
import (
"bytes"
"encoding/json"
"image"
"image/png"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/BurntSushi/toml"
)
// newTestServer builds a Server over fresh temp content/output dirs with a
// couple of posts, runs one build, and returns it.
func newTestServer(t *testing.T) (*Server, string, string) {
t.Helper()
content := t.TempDir()
output := t.TempDir()
writePNG(t, filepath.Join(content, "foo.png"))
if err := os.WriteFile(filepath.Join(content, "foo.toml"),
[]byte("caption = \"Foo\"\ndate = \"2026-01-02\"\ntags = [\"berlin\", \"street\"]\n"), 0600); err != nil {
t.Fatal(err)
}
writePNG(t, filepath.Join(content, "bar.png")) // no sidecar
g := NewGenerator(content, output, "http://x", "A", "@a", "bio", "", "f", "s", "m", 60, viewTimeline)
if err := g.Build(); err != nil {
t.Fatalf("initial build: %v", err)
}
s := NewServer("0", content, output, false, g, nil)
// Make rebuilds a no-op; tests call Build explicitly when they need to
// assert on generated output, so no background goroutine races teardown.
s.rebuildHook = func() {}
return s, content, output
}
func writePNG(t *testing.T, path string) {
t.Helper()
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if err := png.Encode(f, image.NewRGBA(image.Rect(0, 0, 2, 2))); err != nil {
t.Fatal(err)
}
}
func form(v url.Values) *http.Request {
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(v.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return r
}
// multipartForm builds a multipart/form-data request, as a browser's FormData
// does — the content type the manage handlers must parse, not just urlencoded.
func multipartForm(fields map[string]string) *http.Request {
var b bytes.Buffer
mw := multipart.NewWriter(&b)
for k, v := range fields {
mw.WriteField(k, v)
}
mw.Close()
r := httptest.NewRequest(http.MethodPost, "/", &b)
r.Header.Set("Content-Type", mw.FormDataContentType())
return r
}
func readMeta(t *testing.T, content, slug string) PhotoMeta {
t.Helper()
var m PhotoMeta
if _, err := toml.DecodeFile(filepath.Join(content, slug+".toml"), &m); err != nil {
t.Fatalf("decode %s: %v", slug, err)
}
return m
}
func TestResolveSlug(t *testing.T) {
s, _, _ := newTestServer(t)
cases := map[string]bool{
"foo": true,
"bar": true,
"nope": false,
"../server": false,
"foo/../foo": false,
".trash": false,
"": false,
"sub/foo": false,
}
for slug, want := range cases {
if _, ok := s.resolveSlug(slug); ok != want {
t.Errorf("resolveSlug(%q) = %v, want %v", slug, ok, want)
}
}
}
func TestHandleEdit(t *testing.T) {
s, content, _ := newTestServer(t)
rec := httptest.NewRecorder()
s.handleEdit(rec, form(url.Values{
"slug": {"foo"},
"caption": {"New caption"},
"date": {"2020-05-06"},
"tags": {"Paris, , night"},
}))
if rec.Code != http.StatusOK {
t.Fatalf("edit: code=%d body=%s", rec.Code, rec.Body)
}
m := readMeta(t, content, "foo")
if m.Caption != "New caption" || m.Date != "2020-05-06" {
t.Errorf("caption/date not saved: %+v", m)
}
if strings.Join(m.Tags, ",") != "paris,night" { // lowercased, empty dropped
t.Errorf("tags = %v, want [paris night]", m.Tags)
}
// Bad date rejected.
rec = httptest.NewRecorder()
s.handleEdit(rec, form(url.Values{"slug": {"foo"}, "date": {"06-05-2020"}}))
if rec.Code != http.StatusBadRequest {
t.Errorf("bad date: code=%d, want 400", rec.Code)
}
// Unknown slug rejected (and cannot escape content dir).
rec = httptest.NewRecorder()
s.handleEdit(rec, form(url.Values{"slug": {"../evil"}, "date": {"2020-01-01"}}))
if rec.Code != http.StatusNotFound {
t.Errorf("unknown slug: code=%d, want 404", rec.Code)
}
}
func TestHandleBulkTags(t *testing.T) {
s, content, _ := newTestServer(t)
// Add to bar (no sidecar yet) and foo (already has tags).
rec := httptest.NewRecorder()
s.handleBulkTags(rec, form(url.Values{"slugs": {"foo,bar"}, "add": {"aqua, berlin"}}))
if rec.Code != http.StatusOK {
t.Fatalf("bulk add: code=%d body=%s", rec.Code, rec.Body)
}
foo := readMeta(t, content, "foo")
// berlin already present → not duplicated; aqua appended.
if got := strings.Join(foo.Tags, ","); got != "berlin,street,aqua" {
t.Errorf("foo tags = %q, want berlin,street,aqua", got)
}
if got := strings.Join(readMeta(t, content, "bar").Tags, ","); got != "aqua,berlin" {
t.Errorf("bar tags = %q, want aqua,berlin", got)
}
// Remove across both.
rec = httptest.NewRecorder()
s.handleBulkTags(rec, form(url.Values{"slugs": {"foo,bar"}, "remove": {"berlin"}}))
if rec.Code != http.StatusOK {
t.Fatalf("bulk remove: code=%d", rec.Code)
}
if got := strings.Join(readMeta(t, content, "foo").Tags, ","); got != "street,aqua" {
t.Errorf("foo tags after remove = %q, want street,aqua", got)
}
// Empty add+remove is a 400.
rec = httptest.NewRecorder()
s.handleBulkTags(rec, form(url.Values{"slugs": {"foo"}}))
if rec.Code != http.StatusBadRequest {
t.Errorf("empty bulk: code=%d, want 400", rec.Code)
}
}
func TestHandleDeleteMovesToTrashAndPrunes(t *testing.T) {
s, content, output := newTestServer(t)
// Pre-condition: foo's rendition and page exist.
for _, p := range []string{"thumbs/foo.jpg", "images/foo.png", "photo/foo/index.html"} {
if _, err := os.Stat(filepath.Join(output, p)); err != nil {
t.Fatalf("expected %s before delete: %v", p, err)
}
}
rec := httptest.NewRecorder()
s.handleDelete(rec, form(url.Values{"slug": {"foo"}}))
if rec.Code != http.StatusOK {
t.Fatalf("delete: code=%d", rec.Code)
}
var res map[string]int
json.Unmarshal(rec.Body.Bytes(), &res)
if res["deleted"] != 1 {
t.Errorf("deleted = %d, want 1", res["deleted"])
}
// Source files moved out of content/ ...
if _, err := os.Stat(filepath.Join(content, "foo.png")); !os.IsNotExist(err) {
t.Error("foo.png should be gone from content")
}
// ... and into .trash/ (timestamp-suffixed).
trash, _ := os.ReadDir(filepath.Join(content, ".trash"))
var png, tml bool
for _, e := range trash {
if strings.HasPrefix(e.Name(), "foo.") && strings.HasSuffix(e.Name(), ".png") {
png = true
}
if strings.HasPrefix(e.Name(), "foo.") && strings.HasSuffix(e.Name(), ".toml") {
tml = true
}
}
if !png || !tml {
t.Errorf("trash missing files: png=%v toml=%v entries=%v", png, tml, trash)
}
// The handler rebuilds asynchronously; run a deterministic build, then
// confirm the prune removed foo's output and kept bar's.
if err := s.generator.Build(); err != nil {
t.Fatal(err)
}
for _, p := range []string{"thumbs/foo.jpg", "images/foo.png", "photo/foo"} {
if _, err := os.Stat(filepath.Join(output, p)); !os.IsNotExist(err) {
t.Errorf("%s should have been pruned", p)
}
}
if _, err := os.Stat(filepath.Join(output, "thumbs/bar.jpg")); err != nil {
t.Errorf("bar's thumb should survive: %v", err)
}
}
// TestMultipartBodies guards the regression where the manage handlers used
// r.ParseForm (urlencoded only) and silently ignored the multipart bodies that
// browsers send via FormData, returning 200 with deleted:0 / no change.
func TestMultipartBodies(t *testing.T) {
s, content, _ := newTestServer(t)
// Edit via multipart.
rec := httptest.NewRecorder()
s.handleEdit(rec, multipartForm(map[string]string{
"slug": "foo", "caption": "Multipart", "date": "2021-02-03", "tags": "x,y",
}))
if rec.Code != http.StatusOK {
t.Fatalf("multipart edit: code=%d", rec.Code)
}
if m := readMeta(t, content, "foo"); m.Caption != "Multipart" || strings.Join(m.Tags, ",") != "x,y" {
t.Errorf("multipart edit not applied: %+v", m)
}
// Delete via multipart actually moves the file.
rec = httptest.NewRecorder()
s.handleDelete(rec, multipartForm(map[string]string{"slug": "bar"}))
var res map[string]int
json.Unmarshal(rec.Body.Bytes(), &res)
if res["deleted"] != 1 {
t.Fatalf("multipart delete: deleted=%d, want 1 (body=%s)", res["deleted"], rec.Body)
}
if _, err := os.Stat(filepath.Join(content, "bar.png")); !os.IsNotExist(err) {
t.Error("bar.png should have moved to trash")
}
}
func TestCSRFGuard(t *testing.T) {
s, _, _ := newTestServer(t) // generator.baseURL == "http://x"
// A cross-site Origin is refused before any mutation runs.
r := form(url.Values{"slug": {"foo"}})
r.Header.Set("Origin", "http://evil.example")
rec := httptest.NewRecorder()
s.handleDelete(rec, r)
if rec.Code != http.StatusForbidden {
t.Errorf("cross-site Origin: code=%d, want 403", rec.Code)
}
// Sec-Fetch-Site: cross-site is likewise refused.
r = form(url.Values{"slug": {"foo"}})
r.Header.Set("Sec-Fetch-Site", "cross-site")
rec = httptest.NewRecorder()
s.handleDelete(rec, r)
if rec.Code != http.StatusForbidden {
t.Errorf("Sec-Fetch cross-site: code=%d, want 403", rec.Code)
}
// Same-origin is allowed through (no slugs → deletes nothing, 200).
r = form(url.Values{})
r.Header.Set("Sec-Fetch-Site", "same-origin")
rec = httptest.NewRecorder()
s.handleDelete(rec, r)
if rec.Code != http.StatusOK {
t.Errorf("same-origin: code=%d, want 200", rec.Code)
}
// A matching Origin is allowed.
r = form(url.Values{})
r.Header.Set("Origin", "http://x")
rec = httptest.NewRecorder()
s.handleDelete(rec, r)
if rec.Code != http.StatusOK {
t.Errorf("matching Origin: code=%d, want 200", rec.Code)
}
}
func TestHandleListPhotos(t *testing.T) {
s, _, _ := newTestServer(t)
rec := httptest.NewRecorder()
s.handleListPhotos(rec, httptest.NewRequest(http.MethodGet, "/api/photos", nil))
if rec.Code != http.StatusOK {
t.Fatalf("list: code=%d", rec.Code)
}
var photos []photoJSON
if err := json.Unmarshal(rec.Body.Bytes(), &photos); err != nil {
t.Fatalf("decode: %v", err)
}
if len(photos) != 2 {
t.Fatalf("got %d photos, want 2", len(photos))
}
var foo *photoJSON
for i := range photos {
if photos[i].Slug == "foo" {
foo = &photos[i]
}
}
if foo == nil {
t.Fatal("foo not in list")
}
if foo.Thumb != "/thumbs/foo.jpg" || foo.URL != "/photo/foo/" {
t.Errorf("foo paths wrong: %+v", foo)
}
if strings.Join(foo.Tags, ",") != "berlin,street" {
t.Errorf("foo tags = %v", foo.Tags)
}
}