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) } }