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