diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-02-13 08:45:49 +0100 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-02-13 08:45:49 +0100 |
| commit | 17be2fcb106ddc9c36628e9f5dece28369affc30 (patch) | |
| tree | 4f3cb1d2fade5467f6c79a9a1abe1da0852a61d0 /gallery_test.go | |
Extract gallery app from perso/vps into standalone project
Extracted all gallery-related files from ~/perso/vps:
- gallery.go, gallery_test.go (Go source + tests)
- templates/ (index, login, gallery HTML)
- static/style.css
- go.mod/go.sum (renamed module to github.com/kagamino/kagapics)
- Caddyfile.example (gallery-only Caddy config)
- DESIGN.md (from galery.md)
Diffstat (limited to 'gallery_test.go')
| -rw-r--r-- | gallery_test.go | 558 |
1 files changed, 558 insertions, 0 deletions
diff --git a/gallery_test.go b/gallery_test.go new file mode 100644 index 0000000..125a19a --- /dev/null +++ b/gallery_test.go @@ -0,0 +1,558 @@ +package main + +import ( + "archive/zip" + "bytes" + "encoding/json" + "fmt" + "image" + "image/color" + "image/jpeg" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func setupTestServer(t *testing.T) (*httptest.Server, *Server) { + t.Helper() + dir := t.TempDir() + srv := NewServer(dir, 20<<20) + + require.NoError(t, srv.createGallery("test-gallery", "secret123")) + + ts := httptest.NewServer(srv.Handler()) + t.Cleanup(ts.Close) + return ts, srv +} + +// noRedirectClient returns an *http.Client that does NOT follow redirects, +// so we can inspect 303 responses and Set-Cookie headers. +func noRedirectClient() *http.Client { + return &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + +// login performs a POST login and returns the session cookies on success. +func login(t *testing.T, ts *httptest.Server, gallery, password string) []*http.Cookie { + t.Helper() + client := noRedirectClient() + + resp, err := client.PostForm(ts.URL+"/g/"+gallery, url.Values{"password": {password}}) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusSeeOther, resp.StatusCode, "login should redirect on success") + return resp.Cookies() +} + +// authedGet performs a GET with the given cookies attached. +func authedGet(t *testing.T, rawURL string, cookies []*http.Cookie) *http.Response { + t.Helper() + req, err := http.NewRequest(http.MethodGet, rawURL, nil) + require.NoError(t, err) + for _, c := range cookies { + req.AddCookie(c) + } + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + return resp +} + +// makeTestJPEG creates a small valid JPEG in memory. +func makeTestJPEG() []byte { + img := image.NewRGBA(image.Rect(0, 0, 10, 10)) + for y := 0; y < 10; y++ { + for x := 0; x < 10; x++ { + img.Set(x, y, color.RGBA{R: 255, A: 255}) + } + } + var buf bytes.Buffer + jpeg.Encode(&buf, img, nil) + return buf.Bytes() +} + +// uploadPhoto uploads a single JPEG to the gallery via multipart POST. +func uploadPhoto(t *testing.T, ts *httptest.Server, gallery string, cookies []*http.Cookie, filename string, data []byte) *http.Response { + t.Helper() + + var body bytes.Buffer + mw := multipart.NewWriter(&body) + part, err := mw.CreateFormFile("photos", filename) + require.NoError(t, err) + _, err = part.Write(data) + require.NoError(t, err) + require.NoError(t, mw.Close()) + + req, err := http.NewRequest(http.MethodPost, ts.URL+"/g/"+gallery+"/upload", &body) + require.NoError(t, err) + req.Header.Set("Content-Type", mw.FormDataContentType()) + for _, c := range cookies { + req.AddCookie(c) + } + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + return resp +} + +// bodyString reads and returns the full response body as a string. +func bodyString(t *testing.T, resp *http.Response) string { + t.Helper() + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + return string(b) +} + +// setupWithPhoto creates a server, logs in, uploads one photo, and returns +// everything needed for subsequent assertions. +func setupWithPhoto(t *testing.T, filename string) (*httptest.Server, *Server, []*http.Cookie) { + t.Helper() + ts, srv := setupTestServer(t) + cookies := login(t, ts, "test-gallery", "secret123") + + resp := uploadPhoto(t, ts, "test-gallery", cookies, filename, makeTestJPEG()) + defer resp.Body.Close() + + var result map[string]int + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + require.Equal(t, 1, result["uploaded"]) + + return ts, srv, cookies +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +func TestIndexListsGalleries(t *testing.T) { + ts, _ := setupTestServer(t) + + resp, err := http.Get(ts.URL + "/") + require.NoError(t, err) + body := bodyString(t, resp) + + assert.Equal(t, 200, resp.StatusCode) + assert.Contains(t, body, "test-gallery") +} + +func TestLoginFlow(t *testing.T) { + ts, _ := setupTestServer(t) + client := noRedirectClient() + + resp, err := client.PostForm(ts.URL+"/g/test-gallery", url.Values{"password": {"secret123"}}) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusSeeOther, resp.StatusCode) + + var found bool + for _, c := range resp.Cookies() { + if c.Name == "session_test-gallery" && c.Value != "" { + found = true + } + } + assert.True(t, found, "session cookie should be set after login") +} + +func TestSessionCookieHasSecureFlag(t *testing.T) { + ts, _ := setupTestServer(t) + client := noRedirectClient() + + resp, err := client.PostForm(ts.URL+"/g/test-gallery", url.Values{"password": {"secret123"}}) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusSeeOther, resp.StatusCode) + + var sessionCookie *http.Cookie + for _, c := range resp.Cookies() { + if c.Name == "session_test-gallery" { + sessionCookie = c + break + } + } + require.NotNil(t, sessionCookie, "session cookie must be present") + assert.True(t, sessionCookie.Secure, "session cookie must have the Secure flag set") +} + +func TestLoginWrongPassword(t *testing.T) { + ts, _ := setupTestServer(t) + + resp, err := http.PostForm(ts.URL+"/g/test-gallery", url.Values{"password": {"wrong"}}) + require.NoError(t, err) + body := bodyString(t, resp) + + assert.Equal(t, 200, resp.StatusCode) + assert.Contains(t, body, "Wrong password") +} + +func TestGalleryViewEmpty(t *testing.T) { + ts, _ := setupTestServer(t) + cookies := login(t, ts, "test-gallery", "secret123") + + resp := authedGet(t, ts.URL+"/g/test-gallery", cookies) + body := bodyString(t, resp) + + assert.Equal(t, 200, resp.StatusCode) + assert.Contains(t, body, "No photos yet") +} + +func TestUploadAndView(t *testing.T) { + ts, _, cookies := setupWithPhoto(t, "holiday.jpg") + + resp := authedGet(t, ts.URL+"/g/test-gallery", cookies) + body := bodyString(t, resp) + + assert.Contains(t, body, "holiday.jpg") +} + +func TestThumbServing(t *testing.T) { + ts, _, cookies := setupWithPhoto(t, "thumb-test.jpg") + + resp := authedGet(t, ts.URL+"/g/test-gallery/thumb/thumb-test.jpg", cookies) + defer resp.Body.Close() + + assert.Equal(t, 200, resp.StatusCode) + assert.Contains(t, resp.Header.Get("Content-Type"), "image/jpeg") + + _, err := jpeg.Decode(resp.Body) + assert.NoError(t, err, "thumbnail should be a decodable JPEG") +} + +func TestPhotoServing(t *testing.T) { + ts, _, cookies := setupWithPhoto(t, "original.jpg") + + resp := authedGet(t, ts.URL+"/g/test-gallery/photo/original.jpg", cookies) + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + require.NoError(t, err) + + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, makeTestJPEG(), body, "served photo should match uploaded content") +} + +func TestZipDownload(t *testing.T) { + ts, _, cookies := setupWithPhoto(t, "zip-pic.jpg") + + resp := authedGet(t, ts.URL+"/g/test-gallery/zip", cookies) + zipBody, err := io.ReadAll(resp.Body) + resp.Body.Close() + require.NoError(t, err) + + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, "application/zip", resp.Header.Get("Content-Type")) + + zr, err := zip.NewReader(bytes.NewReader(zipBody), int64(len(zipBody))) + require.NoError(t, err) + + names := make([]string, len(zr.File)) + for i, f := range zr.File { + names[i] = f.Name + } + assert.Contains(t, names, "zip-pic.jpg") +} + +func TestUnauthenticatedAccessBlocked(t *testing.T) { + ts, _, _ := setupWithPhoto(t, "secret.jpg") + + paths := []string{ + "/g/test-gallery", + "/g/test-gallery/photo/secret.jpg", + "/g/test-gallery/thumb/secret.jpg", + "/g/test-gallery/zip", + } + + for _, p := range paths { + resp, err := http.Get(ts.URL + p) + require.NoError(t, err) + body := bodyString(t, resp) + + assert.Contains(t, body, `name="password"`, "path %q should show login form", p) + assert.NotContains(t, body, "secret.jpg", "path %q should not leak photo filename", p) + } +} + +func TestRateLimiting(t *testing.T) { + ts, _ := setupTestServer(t) + + for i := 0; i < loginRateMaxAttempts; i++ { + resp, err := http.PostForm(ts.URL+"/g/test-gallery", url.Values{"password": {"wrong"}}) + require.NoError(t, err) + resp.Body.Close() + assert.Equal(t, 200, resp.StatusCode, "attempt %d should be allowed", i+1) + } + + // The next attempt should be rate-limited + resp, err := http.PostForm(ts.URL+"/g/test-gallery", url.Values{"password": {"wrong"}}) + require.NoError(t, err) + body := bodyString(t, resp) + + assert.Equal(t, http.StatusTooManyRequests, resp.StatusCode) + assert.Contains(t, body, "Too many login attempts") +} + +func TestUploadSizeEnforcedAtReadTime(t *testing.T) { + ts, srv := setupTestServer(t) + srv.maxUploadSize = 256 + cookies := login(t, ts, "test-gallery", "secret123") + + // Upload a file of exactly maxUploadSize bytes (passes the fh.Size + // check: fh.Size <= maxUploadSize). The file must be saved to disk. + exactData := bytes.Repeat([]byte{0xFF}, int(srv.maxUploadSize)) + resp := uploadPhoto(t, ts, "test-gallery", cookies, "exact.jpg", exactData) + var result map[string]int + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + resp.Body.Close() + require.Equal(t, 1, result["uploaded"], "file at exactly maxUploadSize should be accepted") + + // Verify every file on disk is at most maxUploadSize bytes. + // This is the invariant that io.LimitReader enforces as defense-in-depth: + // even if the fh.Size check were somehow bypassed, the copy itself + // must never write more than maxUploadSize bytes. + dir := srv.galleryDir("test-gallery") + entries, err := os.ReadDir(dir) + require.NoError(t, err) + + for _, e := range entries { + if e.IsDir() || !isAllowedExt(e.Name()) { + continue + } + info, err := e.Info() + require.NoError(t, err) + assert.LessOrEqual(t, info.Size(), srv.maxUploadSize, + "file %q on disk (%d bytes) must not exceed maxUploadSize (%d)", + e.Name(), info.Size(), srv.maxUploadSize) + } + + // Now test copyUploadFile directly: given a reader with more data than + // maxUploadSize, the copy must be capped at maxUploadSize bytes. + oversized := bytes.NewReader(bytes.Repeat([]byte{0xAB}, int(srv.maxUploadSize)+500)) + tmpPath := filepath.Join(t.TempDir(), "test-copy.dat") + n, err := srv.copyUploadFile(tmpPath, oversized) + require.NoError(t, err) + assert.LessOrEqual(t, n, srv.maxUploadSize, + "copyUploadFile should write at most maxUploadSize bytes, got %d", n) +} + +// makeUnsupportedJPEG creates a JPEG that Go's stdlib cannot decode +// (unsupported luma/chroma subsampling ratio with H=3) but ImageMagick can. +// This simulates real-world JPEGs that use features outside Go's support. +func makeUnsupportedJPEG() []byte { + img := image.NewRGBA(image.Rect(0, 0, 200, 200)) + for y := 0; y < 200; y++ { + for x := 0; x < 200; x++ { + img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 128, A: 255}) + } + } + var buf bytes.Buffer + jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}) + data := buf.Bytes() + + // Find the SOF0 marker (FF C0) and change luma sampling factor to H=3, V=1. + // Go's image/jpeg rejects h=3 or v=3 as "unsupported subsampling ratio", + // but ImageMagick handles it gracefully. + for i := 0; i < len(data)-1; i++ { + if data[i] == 0xFF && data[i+1] == 0xC0 { + // SOF0 header: marker(2) + length(2) + precision(1) + height(2) + width(2) + nf(1) + component(3*nf) + compStart := i + 2 + 2 + 1 + 2 + 2 + 1 + data[compStart+1] = 0x31 // H=3, V=1 + break + } + } + return data +} + +func TestThumbFallbackToImageMagick(t *testing.T) { + ts, srv := setupTestServer(t) + cookies := login(t, ts, "test-gallery", "secret123") + + // Create a JPEG that Go's stdlib can't decode but ImageMagick can. + unsupportedJPEG := makeUnsupportedJPEG() + + // Write directly to the gallery dir (bypass upload, test thumbnail generation). + dir := srv.galleryDir("test-gallery") + require.NoError(t, os.WriteFile(filepath.Join(dir, "tricky.jpg"), unsupportedJPEG, 0644)) + + // Request the thumbnail — should use ImageMagick fallback and return a valid JPEG. + resp := authedGet(t, ts.URL+"/g/test-gallery/thumb/tricky.jpg", cookies) + defer resp.Body.Close() + + assert.Equal(t, 200, resp.StatusCode) + assert.Contains(t, resp.Header.Get("Content-Type"), "image/jpeg") + + // The thumbnail should be a valid, fully decodable JPEG. + thumbData, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + _, err = jpeg.Decode(bytes.NewReader(thumbData)) + assert.NoError(t, err, "thumbnail should be a fully decodable JPEG even when Go's stdlib can't decode the original") + + // The thumbnail must not be the raw original served as fallback. + assert.NotEqual(t, unsupportedJPEG, thumbData, + "thumbnail must not be the broken original served as fallback") +} + +// makeLargeTestJPEG creates a larger JPEG that takes more time to thumbnail. +func makeLargeTestJPEG() []byte { + img := image.NewRGBA(image.Rect(0, 0, 3000, 2000)) + for y := 0; y < 2000; y++ { + for x := 0; x < 3000; x++ { + img.Set(x, y, color.RGBA{R: uint8(x % 256), G: uint8(y % 256), B: 128, A: 255}) + } + } + var buf bytes.Buffer + jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}) + return buf.Bytes() +} + +func TestConcurrentThumbnailGeneration(t *testing.T) { + ts, srv := setupTestServer(t) + cookies := login(t, ts, "test-gallery", "secret123") + + // Upload 40 photos directly to the gallery dir (bypass upload handler for speed). + dir := srv.galleryDir("test-gallery") + largeJPEG := makeLargeTestJPEG() + numPhotos := 40 + for i := 0; i < numPhotos; i++ { + filename := fmt.Sprintf("photo_%03d.jpg", i) + require.NoError(t, os.WriteFile(filepath.Join(dir, filename), largeJPEG, 0644)) + } + + // Fire all thumbnail requests concurrently, like a browser would. + var wg sync.WaitGroup + var failures atomic.Int32 + deadline := 30 * time.Second + + for i := 0; i < numPhotos; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + filename := fmt.Sprintf("photo_%03d.jpg", idx) + + client := &http.Client{Timeout: deadline} + req, err := http.NewRequest(http.MethodGet, ts.URL+"/g/test-gallery/thumb/"+filename, nil) + if err != nil { + failures.Add(1) + return + } + for _, c := range cookies { + req.AddCookie(c) + } + + resp, err := client.Do(req) + if err != nil { + failures.Add(1) + return + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + failures.Add(1) + return + } + + // Must be a valid JPEG + _, err = jpeg.Decode(resp.Body) + if err != nil { + failures.Add(1) + } + }(i) + } + + wg.Wait() + assert.Equal(t, int32(0), failures.Load(), + "all %d concurrent thumbnail requests should succeed, but %d failed", numPhotos, failures.Load()) + + // Verify that thumbnail generation concurrency was bounded. + // With thumbSemaphore in place, peak concurrency must not exceed the limit. + peak := srv.peakThumbConcurrency.Load() + t.Logf("peak thumbnail generation concurrency: %d", peak) + assert.LessOrEqual(t, peak, int32(maxThumbConcurrency), + "peak concurrency (%d) should not exceed maxThumbConcurrency (%d)", peak, maxThumbConcurrency) +} + +func setupTestServerNoThumbs(t *testing.T) (*httptest.Server, *Server) { + t.Helper() + dir := t.TempDir() + srv := NewServer(dir, 20<<20) + + require.NoError(t, srv.createGalleryOpts("test-gallery", "secret123", false)) + + ts := httptest.NewServer(srv.Handler()) + t.Cleanup(ts.Close) + return ts, srv +} + +func TestNoThumbsGalleryServesSourceInGrid(t *testing.T) { + ts, srv := setupTestServerNoThumbs(t) + cookies := login(t, ts, "test-gallery", "secret123") + + // Upload a photo directly. + dir := srv.galleryDir("test-gallery") + require.NoError(t, os.WriteFile(filepath.Join(dir, "pic.jpg"), makeTestJPEG(), 0644)) + + // The gallery HTML should use /photo/ URLs, not /thumb/ URLs. + resp := authedGet(t, ts.URL+"/g/test-gallery", cookies) + body := bodyString(t, resp) + + assert.Contains(t, body, "/g/test-gallery/photo/pic.jpg", + "gallery grid should use photo URLs when thumbnails are disabled") + assert.NotContains(t, body, "/g/test-gallery/thumb/pic.jpg", + "gallery grid should NOT use thumb URLs when thumbnails are disabled") +} + +func TestThumbsEnabledGalleryServesThumbsInGrid(t *testing.T) { + ts, srv := setupTestServer(t) // default: thumbs enabled + cookies := login(t, ts, "test-gallery", "secret123") + + dir := srv.galleryDir("test-gallery") + require.NoError(t, os.WriteFile(filepath.Join(dir, "pic.jpg"), makeTestJPEG(), 0644)) + + resp := authedGet(t, ts.URL+"/g/test-gallery", cookies) + body := bodyString(t, resp) + + assert.Contains(t, body, "/g/test-gallery/thumb/pic.jpg", + "gallery grid should use thumb URLs when thumbnails are enabled") +} + +func TestInvalidGalleryName404(t *testing.T) { + ts, _ := setupTestServer(t) + + cases := []struct { + name string + path string + }{ + {"path traversal", "/g/../../etc"}, + {"spaces", "/g/foo bar"}, + {"dots", "/g/hello.world"}, + {"xss", "/g/<script>"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resp, err := http.Get(ts.URL + tc.path) + require.NoError(t, err) + resp.Body.Close() + assert.Equal(t, 404, resp.StatusCode) + }) + } +} |
