2026-09-28 06:43:44 +02:00
|
|
|
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",
|
2026-09-28 07:10:29 +02:00
|
|
|
"Ada", "", "", "", "", "Crimson Pro", "Manjari", 60, viewTimeline)
|
2026-09-28 06:43:44 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-28 08:22:18 +02:00
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|