Files
photog/manage_test.go
T

356 lines
11 KiB
Go
Raw Normal View History

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, true, g)
}
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)
}
}
})
}
}
func TestManageRoutesNeedTheFlag(t *testing.T) {
for _, manage := range []bool{false, true} {
s := newTestServer(t)
s.manage = manage
mux := s.routes()
want := http.StatusNotFound
if manage {
want = http.StatusOK
}
for _, path := range []string{"/manage", "/manage/edit/sunrise", "/manage/delete/sunrise"} {
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
if w.Code != want {
t.Errorf("manage=%v: GET %s = %d, want %d", manage, path, w.Code, want)
}
}
// The delete POST only does something with the flag: without it the
// photo must survive, with it (no Origin header, as from curl) it goes.
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("POST", "/manage/delete/sunrise", nil))
_, err := os.Stat(filepath.Join(s.contentDir, "sunrise.jpg"))
present := err == nil
if present == manage {
t.Errorf("manage=%v: POST delete = %d, photo present afterwards = %v", manage, w.Code, present)
}
// /upload and /health are there regardless.
for _, path := range []string{"/upload", "/health"} {
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
if w.Code != http.StatusOK {
t.Errorf("manage=%v: GET %s = %d, want 200", manage, path, w.Code)
}
}
}
}