package main import ( "bytes" "image" "image/jpeg" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "testing" "github.com/BurntSushi/toml" ) // newTestServer returns a server over a content directory holding one photo, // "sunrise", with a sidecar. func newTestServer(t *testing.T) *Server { t.Helper() // Not t.TempDir(): an edit kicks off a background rebuild that can still // be writing when the test ends, and TempDir fails the test if its // cleanup finds the directory busy. root, err := os.MkdirTemp("", "photog-test-") if err != nil { t.Fatal(err) } t.Cleanup(func() { os.RemoveAll(root) }) content := filepath.Join(root, "content") output := filepath.Join(root, "public") for _, d := range []string{content, output} { if err := os.MkdirAll(d, 0755); err != nil { t.Fatal(err) } } var img bytes.Buffer if err := jpeg.Encode(&img, image.NewRGBA(image.Rect(0, 0, 8, 8)), nil); err != nil { t.Fatal(err) } writeFile(t, filepath.Join(content, "sunrise.jpg"), img.Bytes()) writeFile(t, filepath.Join(content, "sunrise.toml"), []byte("caption = \"Tempelhof at sunrise\"\ndate = \"2026-06-14\"\n")) g := NewGenerator(content, output, "https://photos.example.com", "Ada", "", "", "", "", "Crimson Pro", "Manjari", 60, viewTimeline) return NewServer("0", content, output, false, g) } func writeFile(t *testing.T, path string, data []byte) { t.Helper() if err := os.WriteFile(path, data, 0644); err != nil { t.Fatal(err) } } func readMeta(t *testing.T, s *Server, slug string) PhotoMeta { t.Helper() var meta PhotoMeta if _, err := toml.DecodeFile(filepath.Join(s.contentDir, slug+".toml"), &meta); err != nil { t.Fatal(err) } return meta } // postEdit submits the edit form for slug and returns the response. func postEdit(s *Server, slug string, form url.Values, headers map[string]string) *httptest.ResponseRecorder { r := httptest.NewRequest("POST", "/manage/edit/"+slug, strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") for k, v := range headers { r.Header.Set(k, v) } r.SetPathValue("slug", slug) w := httptest.NewRecorder() s.handleEdit(w, r) return w } func TestManagePageListsPhotos(t *testing.T) { s := newTestServer(t) w := httptest.NewRecorder() s.handleManagePage(w, httptest.NewRequest("GET", "/manage", nil)) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200", w.Code) } body := w.Body.String() for _, want := range []string{"Tempelhof at sunrise", "2026-06-14", `href="/manage/edit/sunrise"`} { if !strings.Contains(body, want) { t.Errorf("manage page is missing %q", want) } } } func TestEditPagePrefillsForm(t *testing.T) { s := newTestServer(t) r := httptest.NewRequest("GET", "/manage/edit/sunrise", nil) r.SetPathValue("slug", "sunrise") w := httptest.NewRecorder() s.handleEditPage(w, r) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200", w.Code) } body := w.Body.String() for _, want := range []string{`value="Tempelhof at sunrise"`, `value="2026-06-14"`} { if !strings.Contains(body, want) { t.Errorf("edit page is missing %q", want) } } } func TestEditRewritesSidecar(t *testing.T) { s := newTestServer(t) form := url.Values{"caption": {`Tempelhof, "golden hour"`}, "date": {"2026-06-15"}} w := postEdit(s, "sunrise", form, nil) if w.Code != http.StatusSeeOther { t.Fatalf("status = %d, want 303: %s", w.Code, w.Body) } if loc := w.Header().Get("Location"); loc != "/manage?saved=sunrise" { t.Errorf("Location = %q", loc) } meta := readMeta(t, s, "sunrise") if meta.Caption != `Tempelhof, "golden hour"` || meta.Date != "2026-06-15" { t.Errorf("sidecar = %+v", meta) } } func TestEditEmptyCaptionFallsBackToSlug(t *testing.T) { s := newTestServer(t) w := postEdit(s, "sunrise", url.Values{"caption": {" "}, "date": {"2026-06-14"}}, nil) if w.Code != http.StatusSeeOther { t.Fatalf("status = %d, want 303", w.Code) } if meta := readMeta(t, s, "sunrise"); meta.Caption != "sunrise" { t.Errorf("caption = %q, want the slug", meta.Caption) } } func TestEditRejectsBadInput(t *testing.T) { valid := url.Values{"caption": {"changed"}, "date": {"2026-06-15"}} tests := []struct { name string slug string form url.Values headers map[string]string want int }{ {"unknown photo", "nope", valid, nil, http.StatusNotFound}, {"path traversal", "../sunrise", valid, nil, http.StatusNotFound}, {"malformed date", "sunrise", url.Values{"caption": {"changed"}, "date": {"15.06.2026"}}, nil, http.StatusBadRequest}, {"missing date", "sunrise", url.Values{"caption": {"changed"}}, nil, http.StatusBadRequest}, {"cross-site fetch", "sunrise", valid, map[string]string{"Sec-Fetch-Site": "cross-site"}, http.StatusForbidden}, {"foreign origin", "sunrise", valid, map[string]string{"Origin": "https://evil.example"}, http.StatusForbidden}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { s := newTestServer(t) w := postEdit(s, tt.slug, tt.form, tt.headers) if w.Code != tt.want { t.Errorf("status = %d, want %d", w.Code, tt.want) } if meta := readMeta(t, s, "sunrise"); meta.Caption != "Tempelhof at sunrise" || meta.Date != "2026-06-14" { t.Errorf("sidecar was changed: %+v", meta) } }) } } func TestEditAcceptsOwnOrigin(t *testing.T) { form := url.Values{"caption": {"changed"}, "date": {"2026-06-15"}} tests := []struct { name string headers map[string]string }{ {"same-origin fetch metadata", map[string]string{"Sec-Fetch-Site": "same-origin"}}, // Behind nginx the Host header is the upstream address, so only // -base-url can vouch for the public origin. {"origin equal to base URL", map[string]string{"Origin": "https://photos.example.com"}}, {"origin equal to host", map[string]string{"Origin": "http://example.com"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { s := newTestServer(t) if w := postEdit(s, "sunrise", form, tt.headers); w.Code != http.StatusSeeOther { t.Errorf("status = %d, want 303: %s", w.Code, w.Body) } }) } } // twoPhotoServer returns a built site with "sunrise" plus "dusk", which has a // day of its own, so deleting it must also take that day's page away. func twoPhotoServer(t *testing.T) *Server { t.Helper() s := newTestServer(t) var img bytes.Buffer if err := jpeg.Encode(&img, image.NewRGBA(image.Rect(0, 0, 8, 8)), nil); err != nil { t.Fatal(err) } writeFile(t, filepath.Join(s.contentDir, "dusk.jpg"), img.Bytes()) writeFile(t, filepath.Join(s.contentDir, "dusk.toml"), []byte("caption = \"Dusk\"\ndate = \"2026-07-01\"\n")) if err := s.generator.Build(); err != nil { t.Fatal(err) } return s } // photoFiles lists every file and directory that belongs to one photo. func photoFiles(s *Server, slug, day string) []string { return []string{ filepath.Join(s.contentDir, slug+".jpg"), filepath.Join(s.contentDir, slug+".toml"), filepath.Join(s.outputDir, "images", slug+".jpg"), filepath.Join(s.outputDir, "thumbs", slug+".jpg"), filepath.Join(s.outputDir, "medium", slug+".jpg"), filepath.Join(s.outputDir, "og", slug+".jpg"), filepath.Join(s.outputDir, "photo", slug), filepath.Join(s.outputDir, "day", day), } } func postDelete(s *Server, slug string, headers map[string]string) *httptest.ResponseRecorder { r := httptest.NewRequest("POST", "/manage/delete/"+slug, nil) for k, v := range headers { r.Header.Set(k, v) } r.SetPathValue("slug", slug) w := httptest.NewRecorder() s.handleDelete(w, r) return w } func TestDeletePageAsksFirst(t *testing.T) { s := twoPhotoServer(t) r := httptest.NewRequest("GET", "/manage/delete/dusk", nil) r.SetPathValue("slug", "dusk") w := httptest.NewRecorder() s.handleDeletePage(w, r) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200", w.Code) } if !strings.Contains(w.Body.String(), `action="/manage/delete/dusk"`) { t.Errorf("confirmation page has no delete form") } // Looking at the confirmation page must not delete anything. for _, f := range photoFiles(s, "dusk", "2026-07-01") { if _, err := os.Stat(f); err != nil { t.Errorf("%s is gone after a GET: %v", f, err) } } } func TestDeleteRemovesPhotoEverywhere(t *testing.T) { s := twoPhotoServer(t) w := postDelete(s, "dusk", nil) if w.Code != http.StatusSeeOther { t.Fatalf("status = %d, want 303: %s", w.Code, w.Body) } if loc := w.Header().Get("Location"); loc != "/manage?deleted=Dusk" { t.Errorf("Location = %q", loc) } // The handler rebuilds in the background; build here to have a // finished site to look at. if err := s.generator.Build(); err != nil { t.Fatal(err) } for _, f := range photoFiles(s, "dusk", "2026-07-01") { if _, err := os.Stat(f); !os.IsNotExist(err) { t.Errorf("%s survived the delete", f) } } for _, f := range photoFiles(s, "sunrise", "2026-06-14") { if _, err := os.Stat(f); err != nil { t.Errorf("the other photo lost %s: %v", f, err) } } for _, page := range []string{"index.html", "grid/index.html", "feed.xml"} { data, err := os.ReadFile(filepath.Join(s.outputDir, page)) if err != nil { t.Fatal(err) } if strings.Contains(string(data), "dusk") { t.Errorf("%s still mentions the deleted photo", page) } } } func TestDeleteRejectsBadInput(t *testing.T) { tests := []struct { name string slug string headers map[string]string want int }{ {"unknown photo", "nope", nil, http.StatusNotFound}, {"path traversal", "../dusk", nil, http.StatusNotFound}, {"cross-site fetch", "dusk", map[string]string{"Sec-Fetch-Site": "cross-site"}, http.StatusForbidden}, {"foreign origin", "dusk", map[string]string{"Origin": "https://evil.example"}, http.StatusForbidden}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { s := twoPhotoServer(t) if w := postDelete(s, tt.slug, tt.headers); w.Code != tt.want { t.Errorf("status = %d, want %d", w.Code, tt.want) } for _, slug := range []string{"dusk", "sunrise"} { if _, err := os.Stat(filepath.Join(s.contentDir, slug+".jpg")); err != nil { t.Errorf("%s was deleted: %v", slug, err) } } }) } }