package httpapi import ( "bytes" "context" "encoding/json" "fmt" "image" "image/color" "image/jpeg" "mime/multipart" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth" "git.azzamo.net/Michilis/Spanglish/photo-api/internal/config" "git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage" "git.azzamo.net/Michilis/Spanglish/photo-api/internal/store" "git.azzamo.net/Michilis/Spanglish/photo-api/internal/worker" ) const testSecret = "test-secret" // Seed ids are UUID-formatted because the Postgres dialect uses uuid columns. const ( uAdmin = "11111111-1111-1111-1111-111111111111" uMember = "22222222-2222-2222-2222-222222222222" uBuyer = "33333333-3333-3333-3333-333333333333" uPending = "44444444-4444-4444-4444-444444444444" uFree = "55555555-5555-5555-5555-555555555555" evPaid = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" evFree = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" ) type testEnv struct { handler http.Handler db *store.DB worker *worker.Worker storagePath string pg bool } // setup migrates a scratch DB (SQLite by default; Postgres when // PHOTO_TEST_PG holds a connection URL), seeds the minimal slice of the // main app schema that access.go reads, and returns a ready handler. func setup(t *testing.T) *testEnv { t.Helper() dir := t.TempDir() cfg := config.Config{ Port: 0, DBType: "sqlite", DatabaseURL: filepath.Join(dir, "test.db"), ViewTokenSecret: testSecret, StoragePath: filepath.Join(dir, "photos"), MaxUploadMB: 5, WorkerConcurrency: 1, FrontendURL: "http://localhost:3000", } if pgURL := os.Getenv("PHOTO_TEST_PG"); pgURL != "" { cfg.DBType = "postgres" cfg.DatabaseURL = pgURL } db, err := store.Open(cfg) if err != nil { t.Fatal(err) } t.Cleanup(func() { db.Close() }) if cfg.DBType == "postgres" { // The scratch Postgres DB persists across tests; start clean. if _, err := db.Exec(`DROP TABLE IF EXISTS photos_photos, photos_galleries, photos_schema_migrations, users, events, tickets, payments, auth_sessions CASCADE`); err != nil { t.Fatal(err) } } if err := db.Migrate(context.Background()); err != nil { t.Fatal(err) } idType := "text" expiresType := "integer" // sqlite: Better Auth stores epoch-ms integers boolType := "integer" falseLit := "0" if cfg.DBType == "postgres" { idType = "uuid" expiresType = "timestamp" boolType = "boolean" falseLit = "FALSE" } seed := []string{ `CREATE TABLE users (id ` + idType + ` PRIMARY KEY, email text, name text, role text, account_status text NOT NULL DEFAULT 'active', banned ` + boolType + ` NOT NULL DEFAULT ` + falseLit + `)`, `CREATE TABLE auth_sessions (id text PRIMARY KEY, user_id ` + idType + `, token text NOT NULL UNIQUE, expires_at ` + expiresType + ` NOT NULL)`, `CREATE TABLE events (id ` + idType + ` PRIMARY KEY, slug text, title text, title_es text, start_datetime text, status text, price real)`, `CREATE TABLE tickets (id text PRIMARY KEY, user_id ` + idType + `, event_id ` + idType + `, status text)`, `CREATE TABLE payments (id text PRIMARY KEY, ticket_id text, status text)`, `INSERT INTO users VALUES ('` + uAdmin + `','a@x.py','Admin','admin','active',` + falseLit + `)`, `INSERT INTO users VALUES ('` + uMember + `','m@x.py','Member','user','active',` + falseLit + `)`, `INSERT INTO users VALUES ('` + uBuyer + `','b@x.py','Buyer','user','active',` + falseLit + `)`, `INSERT INTO users VALUES ('` + uPending + `','p@x.py','Pending','user','active',` + falseLit + `)`, `INSERT INTO users VALUES ('` + uFree + `','f@x.py','Free','user','active',` + falseLit + `)`, `INSERT INTO events VALUES ('` + evPaid + `','fiesta','Fiesta','Fiesta ES','2026-06-01T20:00:00.000Z','completed',50000)`, `INSERT INTO events VALUES ('` + evFree + `','gratis','Gratis','Gratis ES','2026-06-02T20:00:00.000Z','completed',0)`, `INSERT INTO tickets VALUES ('t1','` + uBuyer + `','` + evPaid + `','confirmed')`, `INSERT INTO payments VALUES ('p1','t1','paid')`, `INSERT INTO tickets VALUES ('t2','` + uPending + `','` + evPaid + `','pending')`, `INSERT INTO payments VALUES ('p2','t2','pending')`, `INSERT INTO tickets VALUES ('t3','` + uFree + `','` + evFree + `','confirmed')`, } for _, q := range seed { if _, err := db.Exec(q); err != nil { t.Fatalf("seed %q: %v", q, err) } } st, err := storage.New(cfg) if err != nil { t.Fatal(err) } wrk := worker.New(db, st, nil, cfg.StoragePath, 1) srv := New(cfg, db, st, auth.NewVerifier(db), wrk) return &testEnv{ handler: srv.Handler(), db: db, worker: wrk, storagePath: cfg.StoragePath, pg: cfg.DBType == "postgres", } } // makeToken inserts a Better Auth session row for the user and returns a // bearer value in the cookie's "token.signature" format (the verifier // resolves the token part against auth_sessions; the signature is unused). func (e *testEnv) makeToken(t *testing.T, sub string) string { t.Helper() token := "tok-" + newID() expires := time.Now().Add(time.Hour) var expiresVal any = expires.UnixMilli() if e.pg { expiresVal = expires } if _, err := e.db.Exec(e.db.Rebind( `INSERT INTO auth_sessions (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)`), newID(), sub, token, expiresVal); err != nil { t.Fatal(err) } return token + ".testsig" } func (e *testEnv) request(t *testing.T, method, path, token string, body any) *httptest.ResponseRecorder { t.Helper() var reader *bytes.Reader if body != nil { b, _ := json.Marshal(body) reader = bytes.NewReader(b) } else { reader = bytes.NewReader(nil) } req := httptest.NewRequest(method, path, reader) if body != nil { req.Header.Set("Content-Type", "application/json") } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } w := httptest.NewRecorder() e.handler.ServeHTTP(w, req) return w } func decode[T any](t *testing.T, w *httptest.ResponseRecorder) T { t.Helper() var v T if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil { t.Fatalf("decode %s: %v", w.Body.String(), err) } return v } func testJPEG(t *testing.T) []byte { return testJPEGTinted(t, 128) } // testJPEGTinted varies the blue channel so tests that need two *different* // images (duplicate detection) can get them without a fixture file. func testJPEGTinted(t *testing.T, blue uint8) []byte { t.Helper() img := image.NewRGBA(image.Rect(0, 0, 800, 600)) for x := 0; x < 800; x += 10 { for y := 0; y < 600; y++ { img.Set(x, y, color.RGBA{R: uint8(x % 255), G: uint8(y % 255), B: blue, A: 255}) } } var buf bytes.Buffer if err := jpeg.Encode(&buf, img, nil); err != nil { t.Fatal(err) } return buf.Bytes() } func TestHealth(t *testing.T) { e := setup(t) if w := e.request(t, "GET", "/api/photos/health", "", nil); w.Code != 200 { t.Fatalf("health: %d", w.Code) } } func TestAdminGate(t *testing.T) { e := setup(t) body := map[string]string{"title": "Test"} if w := e.request(t, "POST", "/api/photos/galleries", "", body); w.Code != 401 { t.Fatalf("anon create: want 401, got %d", w.Code) } member := e.makeToken(t, uMember) if w := e.request(t, "POST", "/api/photos/galleries", member, body); w.Code != 403 { t.Fatalf("member create: want 403, got %d", w.Code) } unknown := e.makeToken(t, newID()) if w := e.request(t, "POST", "/api/photos/galleries", unknown, body); w.Code != 401 { t.Fatalf("unknown-user token: want 401, got %d", w.Code) } } func TestSessionValidation(t *testing.T) { e := setup(t) body := map[string]string{"title": "Test"} // Expired session -> 401 expiredTok := "tok-" + newID() var expiresVal any = time.Now().Add(-time.Hour).UnixMilli() if e.pg { expiresVal = time.Now().Add(-time.Hour) } if _, err := e.db.Exec(e.db.Rebind( `INSERT INTO auth_sessions (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)`), newID(), uAdmin, expiredTok, expiresVal); err != nil { t.Fatal(err) } if w := e.request(t, "POST", "/api/photos/galleries", expiredTok+".sig", body); w.Code != 401 { t.Fatalf("expired session: want 401, got %d", w.Code) } // Banned (suspended) user -> 401 even with a live session bannedTok := e.makeToken(t, uMember) if _, err := e.db.Exec(e.db.Rebind( `UPDATE users SET account_status = 'suspended' WHERE id = ?`), uMember); err != nil { t.Fatal(err) } if w := e.request(t, "POST", "/api/photos/galleries", bannedTok, body); w.Code != 401 { t.Fatalf("suspended user: want 401, got %d", w.Code) } if _, err := e.db.Exec(e.db.Rebind( `UPDATE users SET account_status = 'active' WHERE id = ?`), uMember); err != nil { t.Fatal(err) } // Session via the browser cookie (URL-encoded token.signature) -> works adminTok := e.makeToken(t, uAdmin) req := httptest.NewRequest("POST", "/api/photos/galleries", bytes.NewReader([]byte(`{"title":"Via cookie"}`))) req.Header.Set("Content-Type", "application/json") req.AddCookie(&http.Cookie{Name: "spanglish.session_token", Value: strings.ReplaceAll(adminTok, ".", "%2E")}) w := httptest.NewRecorder() e.handler.ServeHTTP(w, req) if w.Code != 200 && w.Code != 201 { t.Fatalf("cookie auth: want 2xx, got %d: %s", w.Code, w.Body.String()) } } type galleryResp struct { Gallery galleryJSON `json:"gallery"` Photos []photoJSON `json:"photos"` } func createGallery(t *testing.T, e *testEnv, admin string, body map[string]any) galleryJSON { t.Helper() w := e.request(t, "POST", "/api/photos/galleries", admin, body) if w.Code != 201 { t.Fatalf("create gallery: %d %s", w.Code, w.Body.String()) } return decode[galleryResp](t, w).Gallery } func uploadPhoto(t *testing.T, e *testEnv, admin, galleryID string, file []byte) photoJSON { t.Helper() var buf bytes.Buffer mw := multipart.NewWriter(&buf) fw, _ := mw.CreateFormFile("files", "test photo.jpg") fw.Write(file) mw.Close() req := httptest.NewRequest("POST", "/api/photos/galleries/"+galleryID+"/photos", &buf) req.Header.Set("Content-Type", mw.FormDataContentType()) req.Header.Set("Authorization", "Bearer "+admin) w := httptest.NewRecorder() e.handler.ServeHTTP(w, req) if w.Code != 201 { t.Fatalf("upload: %d %s", w.Code, w.Body.String()) } photos := decode[struct { Photos []photoJSON `json:"photos"` }](t, w).Photos if len(photos) != 1 { t.Fatalf("want 1 photo, got %d", len(photos)) } return photos[0] } // uploadFiles posts several parts in one request, the way the API allows even // though the admin UI sends one file per request. func uploadFiles(t *testing.T, e *testEnv, admin, galleryID string, files ...[]byte) []photoJSON { t.Helper() var buf bytes.Buffer mw := multipart.NewWriter(&buf) for i, f := range files { fw, _ := mw.CreateFormFile("files", fmt.Sprintf("photo-%d.jpg", i)) fw.Write(f) } mw.Close() req := httptest.NewRequest("POST", "/api/photos/galleries/"+galleryID+"/photos", &buf) req.Header.Set("Content-Type", mw.FormDataContentType()) req.Header.Set("Authorization", "Bearer "+admin) w := httptest.NewRecorder() e.handler.ServeHTTP(w, req) if w.Code != 201 { t.Fatalf("upload: %d %s", w.Code, w.Body.String()) } return decode[struct { Photos []photoJSON `json:"photos"` }](t, w).Photos } // countOriginals is how many objects actually landed on disk for a gallery — // a duplicate must not add one. func countOriginals(t *testing.T, e *testEnv, galleryID string) int { t.Helper() entries, err := os.ReadDir(filepath.Join(e.storagePath, "galleries", galleryID, "orig")) if os.IsNotExist(err) { return 0 } if err != nil { t.Fatal(err) } return len(entries) } func TestUploadSkipsDuplicates(t *testing.T) { e := setup(t) admin := e.makeToken(t, uAdmin) g := createGallery(t, e, admin, map[string]any{"title": "Dupes"}) img := testJPEG(t) first := uploadPhoto(t, e, admin, g.ID, img) if first.Duplicate { t.Fatalf("first upload flagged as duplicate: %+v", first) } // Same bytes again: echoed back as the existing photo, nothing stored. second := uploadPhoto(t, e, admin, g.ID, img) if !second.Duplicate { t.Fatalf("second upload not flagged as duplicate: %+v", second) } if second.ID != first.ID { t.Fatalf("duplicate should echo the existing photo: got %s, want %s", second.ID, first.ID) } photos, err := e.db.ListPhotos(context.Background(), g.ID, false) if err != nil { t.Fatal(err) } if len(photos) != 1 { t.Fatalf("want 1 row after re-upload, got %d", len(photos)) } if n := countOriginals(t, e, g.ID); n != 1 { t.Fatalf("want 1 stored original after re-upload, got %d", n) } if photos[0].Checksum == "" { t.Fatal("checksum was not recorded") } // A different image is unaffected and takes the next position. other := uploadPhoto(t, e, admin, g.ID, testJPEGTinted(t, 32)) if other.Duplicate || other.ID == first.ID { t.Fatalf("distinct image treated as duplicate: %+v", other) } if other.Position != 1 { t.Fatalf("want position 1 for the second distinct photo, got %d", other.Position) } // Duplicate scope is one gallery: the same bytes elsewhere upload normally. g2 := createGallery(t, e, admin, map[string]any{"title": "Other gallery"}) elsewhere := uploadPhoto(t, e, admin, g2.ID, img) if elsewhere.Duplicate { t.Fatalf("same image in another gallery must not be a duplicate: %+v", elsewhere) } if elsewhere.ID == first.ID { t.Fatal("second gallery should get its own photo row") } } func TestUploadBatchContinuesPastDuplicate(t *testing.T) { e := setup(t) admin := e.makeToken(t, uAdmin) g := createGallery(t, e, admin, map[string]any{"title": "Batch"}) a, b, c := testJPEG(t), testJPEGTinted(t, 32), testJPEGTinted(t, 200) if got := uploadFiles(t, e, admin, g.ID, a); len(got) != 1 { t.Fatalf("seed upload: %d photos", len(got)) } // b duplicates nothing, a is already there, c is new: the batch must not // abort, and the duplicate must not consume a position. batch := uploadFiles(t, e, admin, g.ID, b, a, c) if len(batch) != 3 { t.Fatalf("want 3 results, got %d", len(batch)) } if batch[0].Duplicate || !batch[1].Duplicate || batch[2].Duplicate { t.Fatalf("duplicate flags: %v %v %v", batch[0].Duplicate, batch[1].Duplicate, batch[2].Duplicate) } if batch[0].Position != 1 || batch[2].Position != 2 { t.Fatalf("positions should stay dense: %d, %d", batch[0].Position, batch[2].Position) } photos, err := e.db.ListPhotos(context.Background(), g.ID, false) if err != nil { t.Fatal(err) } if len(photos) != 3 { t.Fatalf("want 3 rows, got %d", len(photos)) } if n := countOriginals(t, e, g.ID); n != 3 { t.Fatalf("want 3 stored originals, got %d", n) } } // processQueue runs the worker until the photo is ready or failed. func processQueue(t *testing.T, e *testEnv, photoID string) store.Photo { t.Helper() ctx, cancel := context.WithCancel(context.Background()) defer cancel() e.worker.Run(ctx) e.worker.Nudge() deadline := time.Now().Add(15 * time.Second) for time.Now().Before(deadline) { p, err := e.db.GetPhoto(ctx, photoID) if err != nil { t.Fatal(err) } if p.Status == "ready" || (p.Status == "failed" && p.Attempts > 0) { return p } time.Sleep(50 * time.Millisecond) } t.Fatal("timeout waiting for processing") return store.Photo{} } func TestUploadProcessAndServe(t *testing.T) { e := setup(t) admin := e.makeToken(t, uAdmin) g := createGallery(t, e, admin, map[string]any{"title": "Fiesta de Junio", "visibility": "public"}) if g.Slug != "fiesta-de-junio" { t.Fatalf("slug: %q", g.Slug) } // Public galleries share a clean URL: the token grants nothing there. if g.ShareToken == "" { t.Fatalf("share token missing: %+v", g) } if strings.Contains(g.ShareURL, "token=") { t.Fatalf("public gallery share url must not carry a token: %s", g.ShareURL) } if !strings.HasSuffix(g.ShareURL, "/photos/"+g.Slug) { t.Fatalf("public share url: %s", g.ShareURL) } p := uploadPhoto(t, e, admin, g.ID, testJPEG(t)) if p.Status != "queued" || p.ContentType != "image/jpeg" { t.Fatalf("uploaded photo: %+v", p) } done := processQueue(t, e, p.ID) if done.Status != "ready" { t.Fatalf("processing failed: %s", done.LastError) } if done.Width != 800 || done.Height != 600 { t.Fatalf("dimensions: %dx%d", done.Width, done.Height) } for _, variant := range []string{"thumb", "preview", "original"} { w := e.request(t, "GET", "/api/photos/files/"+p.ID+"/"+variant, "", nil) if w.Code != 200 { t.Fatalf("%s: %d", variant, w.Code) } if ct := w.Header().Get("Content-Type"); ct != "image/jpeg" { t.Fatalf("%s content type: %s", variant, ct) } } w := e.request(t, "GET", "/api/photos/files/"+p.ID+"/original", "", nil) if cd := w.Header().Get("Content-Disposition"); !strings.Contains(cd, "test photo.jpg") { t.Fatalf("original disposition: %q", cd) } // Bad upload is rejected by magic bytes. var buf bytes.Buffer mw := multipart.NewWriter(&buf) fw, _ := mw.CreateFormFile("files", "notes.txt") fw.Write([]byte("not an image at all")) mw.Close() req := httptest.NewRequest("POST", "/api/photos/galleries/"+g.ID+"/photos", &buf) req.Header.Set("Content-Type", mw.FormDataContentType()) req.Header.Set("Authorization", "Bearer "+admin) rec := httptest.NewRecorder() e.handler.ServeHTTP(rec, req) if rec.Code != 415 { t.Fatalf("text upload: want 415, got %d %s", rec.Code, rec.Body.String()) } } func TestAccessMatrix(t *testing.T) { e := setup(t) admin := e.makeToken(t, uAdmin) member := e.makeToken(t, uMember) buyer := e.makeToken(t, uBuyer) pending := e.makeToken(t, uPending) freeguy := e.makeToken(t, uFree) get := func(slug, token, bearer string) int { path := "/api/photos/public/galleries/" + slug if token != "" { path += "?token=" + token } return e.request(t, "GET", path, bearer, nil).Code } // private: 403 with a distinct message so the frontend can show its // gate page (revealing existence is accepted for this community site) priv := createGallery(t, e, admin, map[string]any{"title": "Privada", "visibility": "private"}) for name, code := range map[string]int{"anon": get(priv.Slug, "", ""), "member": get(priv.Slug, "", member), "with-token": get(priv.Slug, priv.ShareToken, "")} { if code != 403 { t.Errorf("private/%s: want 403, got %d", name, code) } } if got := get(priv.Slug, "", admin); got != 200 { t.Errorf("private/admin: want 200, got %d", got) } // link link := createGallery(t, e, admin, map[string]any{"title": "Enlace", "visibility": "link"}) if got := get(link.Slug, "", ""); got != 403 { t.Errorf("link/anon: want 403, got %d", got) } if got := get(link.Slug, "wrong-token", ""); got != 403 { t.Errorf("link/bad-token: want 403, got %d", got) } if got := get(link.Slug, link.ShareToken, ""); got != 200 { t.Errorf("link/token: want 200, got %d", got) } // ticket, paid event tick := createGallery(t, e, admin, map[string]any{"title": "Con Entrada", "visibility": "ticket", "eventId": evPaid}) if got := get(tick.Slug, "", ""); got != 401 { t.Errorf("ticket/anon: want 401, got %d", got) } if got := get(tick.Slug, "", member); got != 403 { t.Errorf("ticket/no-ticket: want 403, got %d", got) } if got := get(tick.Slug, "", pending); got != 403 { t.Errorf("ticket/pending-payment: want 403, got %d", got) } if got := get(tick.Slug, "", buyer); got != 200 { t.Errorf("ticket/paid: want 200, got %d", got) } if got := get(tick.Slug, tick.ShareToken, ""); got != 200 { t.Errorf("ticket/share-token: want 200, got %d", got) } // ticket, free event: any confirmed ticket counts (review decision) free := createGallery(t, e, admin, map[string]any{"title": "Evento Gratis", "visibility": "ticket", "eventId": evFree}) if got := get(free.Slug, "", freeguy); got != 200 { t.Errorf("ticket/free-event-confirmed: want 200, got %d", got) } if got := get(free.Slug, "", member); got != 403 { t.Errorf("ticket/free-event-no-ticket: want 403, got %d", got) } // public index lists only public galleries pub := createGallery(t, e, admin, map[string]any{"title": "Publica", "visibility": "public"}) idx := decode[struct { Galleries []galleryJSON `json:"galleries"` }](t, e.request(t, "GET", "/api/photos/public/galleries", "", nil)) if len(idx.Galleries) != 1 || idx.Galleries[0].ID != pub.ID { t.Errorf("public index: %+v", idx.Galleries) } for _, g := range idx.Galleries { if g.ShareToken != "" { t.Errorf("public index leaks share token") } } } func TestReorderAndVisibilityUpdate(t *testing.T) { e := setup(t) admin := e.makeToken(t, uAdmin) g := createGallery(t, e, admin, map[string]any{"title": "Orden", "visibility": "public"}) // Two distinct images: the same bytes twice would be deduplicated. p1 := uploadPhoto(t, e, admin, g.ID, testJPEG(t)) p2 := uploadPhoto(t, e, admin, g.ID, testJPEGTinted(t, 32)) if w := e.request(t, "PATCH", "/api/photos/galleries/"+g.ID+"/order", admin, map[string]any{"photoIds": []string{p2.ID, p1.ID}}); w.Code != 200 { t.Fatalf("reorder: %d %s", w.Code, w.Body.String()) } detail := decode[galleryResp](t, e.request(t, "GET", "/api/photos/galleries/"+g.ID, admin, nil)) if len(detail.Photos) != 2 || detail.Photos[0].ID != p2.ID { t.Fatalf("order not applied: %+v", detail.Photos) } if w := e.request(t, "PATCH", "/api/photos/galleries/"+g.ID+"/order", admin, map[string]any{"photoIds": []string{p1.ID}}); w.Code != 400 { t.Fatalf("partial reorder: want 400, got %d", w.Code) } if w := e.request(t, "PATCH", "/api/photos/galleries/"+g.ID, admin, map[string]any{"visibility": "banana"}); w.Code != 400 { t.Fatalf("bad visibility: want 400, got %d", w.Code) } if w := e.request(t, "PATCH", "/api/photos/galleries/"+g.ID, admin, map[string]any{"visibility": "link"}); w.Code != 200 { t.Fatalf("set link: %d %s", w.Code, w.Body.String()) } if w := e.request(t, "GET", "/api/photos/public/galleries/"+g.Slug, "", nil); w.Code != 403 { t.Fatalf("after switch to link, anon: want 403, got %d", w.Code) } } // TestViewTokens covers the -tag reality: image requests cannot carry // an Authorization header, so authorized gallery responses embed a // short-lived gallery-scoped token in file URLs that the file handler // accepts anonymously. func TestViewTokens(t *testing.T) { e := setup(t) admin := e.makeToken(t, uAdmin) g := createGallery(t, e, admin, map[string]any{"title": "Privada Con Fotos", "visibility": "private"}) p := uploadPhoto(t, e, admin, g.ID, testJPEG(t)) if processQueue(t, e, p.ID).Status != "ready" { t.Fatal("processing failed") } // The admin detail response must carry tokenized image URLs. detail := decode[galleryResp](t, e.request(t, "GET", "/api/photos/galleries/"+g.ID, admin, nil)) thumbURL := detail.Photos[0].URLs.Thumb if !strings.Contains(thumbURL, "?token=v1.") { t.Fatalf("private gallery photo URL should carry a view token: %s", thumbURL) } // That URL works with NO Authorization header, exactly like an tag. if w := e.request(t, "GET", thumbURL, "", nil); w.Code != 200 { t.Fatalf("anon fetch with view token: want 200, got %d %s", w.Code, w.Body.String()) } // Without the token (or with a forged one) the same file is denied. bare := strings.SplitN(thumbURL, "?", 2)[0] if w := e.request(t, "GET", bare, "", nil); w.Code != 403 { t.Fatalf("anon fetch without token: want 403, got %d", w.Code) } if w := e.request(t, "GET", bare+"?token=v1.9999999999.forged", "", nil); w.Code != 403 { t.Fatalf("forged token: want 403, got %d", w.Code) } // A view token for one gallery must not open another gallery's files. other := createGallery(t, e, admin, map[string]any{"title": "Otra Privada", "visibility": "private"}) op := uploadPhoto(t, e, admin, other.ID, testJPEG(t)) if processQueue(t, e, op.ID).Status != "ready" { t.Fatal("processing failed") } viewToken := strings.SplitN(thumbURL, "?token=", 2)[1] if w := e.request(t, "GET", "/api/photos/files/"+op.ID+"/thumb?token="+viewToken, "", nil); w.Code != 403 { t.Fatalf("cross-gallery token reuse: want 403, got %d", w.Code) } // Public galleries keep clean URLs (no token needed). pub := createGallery(t, e, admin, map[string]any{"title": "Publica Limpia", "visibility": "public"}) pp := uploadPhoto(t, e, admin, pub.ID, testJPEG(t)) if processQueue(t, e, pp.ID).Status != "ready" { t.Fatal("processing failed") } pubDetail := decode[galleryResp](t, e.request(t, "GET", "/api/photos/public/galleries/"+pub.Slug, "", nil)) if strings.Contains(pubDetail.Photos[0].URLs.Thumb, "token=") { t.Fatalf("public photo URL should be clean: %s", pubDetail.Photos[0].URLs.Thumb) } } func TestEventGalleryRoute(t *testing.T) { e := setup(t) admin := e.makeToken(t, uAdmin) buyer := e.makeToken(t, uBuyer) g := createGallery(t, e, admin, map[string]any{"title": "Del Evento", "visibility": "ticket", "eventId": evPaid}) // Event-linked galleries share via the event URL. if !strings.Contains(g.ShareURL, "/events/fiesta/gallery?token="+g.ShareToken) { t.Fatalf("share url should use event route: %s", g.ShareURL) } // /public/events/{slug}/gallery obeys the same access rules. if w := e.request(t, "GET", "/api/photos/public/events/fiesta/gallery", "", nil); w.Code != 401 { t.Fatalf("event gallery anon: want 401, got %d", w.Code) } w := e.request(t, "GET", "/api/photos/public/events/fiesta/gallery", buyer, nil) if w.Code != 200 { t.Fatalf("event gallery buyer: want 200, got %d %s", w.Code, w.Body.String()) } if got := decode[galleryResp](t, w).Gallery.ID; got != g.ID { t.Fatalf("wrong gallery: %s != %s", got, g.ID) } if w := e.request(t, "GET", "/api/photos/public/events/fiesta/gallery?token="+g.ShareToken, "", nil); w.Code != 200 { t.Fatalf("event gallery share token: want 200, got %d", w.Code) } // Unknown event slug and event without a gallery are both 404. if w := e.request(t, "GET", "/api/photos/public/events/nope/gallery", buyer, nil); w.Code != 404 { t.Fatalf("unknown event: want 404, got %d", w.Code) } if w := e.request(t, "GET", "/api/photos/public/events/gratis/gallery", buyer, nil); w.Code != 404 { t.Fatalf("event without gallery: want 404, got %d", w.Code) } // A standalone gallery keeps the /photos share URL; link mode carries // the token, public mode does not. solo := createGallery(t, e, admin, map[string]any{"title": "Sin Evento", "visibility": "link"}) if !strings.Contains(solo.ShareURL, "/photos/sin-evento?token=") { t.Fatalf("standalone share url: %s", solo.ShareURL) } pubEvent := createGallery(t, e, admin, map[string]any{"title": "Publica Con Evento", "visibility": "public", "eventId": evFree}) if !strings.HasSuffix(pubEvent.ShareURL, "/events/gratis/gallery") { t.Fatalf("public event gallery share url should be the clean event link: %s", pubEvent.ShareURL) } } func TestSlugCollision(t *testing.T) { e := setup(t) admin := e.makeToken(t, uAdmin) a := createGallery(t, e, admin, map[string]any{"title": "Misma Fiesta"}) b := createGallery(t, e, admin, map[string]any{"title": "Misma Fiesta"}) if a.Slug == b.Slug { t.Fatalf("slug collision: %s", a.Slug) } if b.Slug != "misma-fiesta-2" { t.Fatalf("second slug: %s", b.Slug) } if a.Visibility != "private" { t.Fatalf("default visibility: %s", a.Visibility) } } // TestDownloadEndpoint pins the guarantees the mobile save sheet is built // on: the bytes arrive same-origin (never a redirect), typed as an image, // named after the event, sized up front and cacheable forever. func TestDownloadEndpoint(t *testing.T) { e := setup(t) admin := e.makeToken(t, uAdmin) g := createGallery(t, e, admin, map[string]any{ "title": "Fotos Del Evento", "visibility": "public", "eventId": evPaid, }) p := uploadPhoto(t, e, admin, g.ID, testJPEG(t)) if processQueue(t, e, p.ID).Status != "ready" { t.Fatal("processing failed") } base := "/api/photos/files/" + p.ID + "/download" for _, tc := range []struct { name, path, wantDisposition, wantFilename string }{ // No ?size at all must behave exactly like ?size=preview. {"default", base, "inline", "spanglish-fiesta-1.jpg"}, {"preview", base + "?size=preview", "inline", "spanglish-fiesta-1.jpg"}, {"original", base + "?size=original", "attachment", "spanglish-fiesta-1.jpg"}, } { t.Run(tc.name, func(t *testing.T) { w := e.request(t, "GET", tc.path, "", nil) if w.Code != 200 { t.Fatalf("status %d %s", w.Code, w.Body.String()) } if ct := w.Header().Get("Content-Type"); ct != "image/jpeg" { t.Fatalf("content type %q: iOS only offers Save Image for image/*", ct) } if n := w.Header().Get("Content-Length"); n != fmt.Sprint(w.Body.Len()) || n == "0" { t.Fatalf("content length %q, body %d bytes", n, w.Body.Len()) } cd := w.Header().Get("Content-Disposition") if !strings.HasPrefix(cd, tc.wantDisposition) || !strings.Contains(cd, tc.wantFilename) { t.Fatalf("disposition %q, want %s with %s", cd, tc.wantDisposition, tc.wantFilename) } if cc := w.Header().Get("Cache-Control"); cc != "public, max-age=31536000, immutable" { t.Fatalf("cache-control %q", cc) } }) } // Each size serves its own variant byte-for-byte. (No size comparison // between the two here: the fixture is a synthetic 800x600 image, so its // 2048px preview re-encode is larger than the "original" — which says // nothing about the multi-megapixel photos the sheet exists for.) prev := e.request(t, "GET", base, "", nil) orig := e.request(t, "GET", base+"?size=original", "", nil) if !bytes.Equal(prev.Body.Bytes(), e.request(t, "GET", "/api/photos/files/"+p.ID+"/preview", "", nil).Body.Bytes()) { t.Fatal("download?size=preview must serve the same bytes as the preview variant") } if !bytes.Equal(orig.Body.Bytes(), e.request(t, "GET", "/api/photos/files/"+p.ID+"/original", "", nil).Body.Bytes()) { t.Fatal("download?size=original must serve the same bytes as the original variant") } // An unknown size is not a silent fallback. if w := e.request(t, "GET", base+"?size=thumb", "", nil); w.Code != 404 { t.Fatalf("unknown size: want 404, got %d", w.Code) } // The gallery response labels both rows without any extra request. detail := decode[galleryResp](t, e.request(t, "GET", "/api/photos/public/galleries/"+g.Slug, "", nil)) got := detail.Photos[0] if got.PreviewSizeBytes != int64(prev.Body.Len()) { t.Fatalf("previewSizeBytes %d, served %d", got.PreviewSizeBytes, prev.Body.Len()) } if got.SizeBytes != int64(orig.Body.Len()) { t.Fatalf("sizeBytes %d, served %d", got.SizeBytes, orig.Body.Len()) } if got.URLs.Download != base { t.Fatalf("download url %q, want %q", got.URLs.Download, base) } if got.URLs.DownloadOriginal != base+"?size=original" { t.Fatalf("downloadOriginal url %q", got.URLs.DownloadOriginal) } } // A restricted gallery's download must obey the same token check as every // other byte-serving route, and must not become shared-cacheable. func TestDownloadRespectsAccess(t *testing.T) { e := setup(t) admin := e.makeToken(t, uAdmin) g := createGallery(t, e, admin, map[string]any{"title": "Privada Descarga", "visibility": "private"}) p := uploadPhoto(t, e, admin, g.ID, testJPEG(t)) if processQueue(t, e, p.ID).Status != "ready" { t.Fatal("processing failed") } detail := decode[galleryResp](t, e.request(t, "GET", "/api/photos/galleries/"+g.ID, admin, nil)) url := detail.Photos[0].URLs.Download if !strings.Contains(url, "?token=v1.") { t.Fatalf("private download URL should carry a view token: %s", url) } // ?size and ?token have to coexist on the original's URL. if o := detail.Photos[0].URLs.DownloadOriginal; !strings.Contains(o, "?size=original&token=v1.") { t.Fatalf("private original download URL: %s", o) } w := e.request(t, "GET", url, "", nil) if w.Code != 200 { t.Fatalf("view token fetch: want 200, got %d", w.Code) } // Restricted galleries stay out of shared caches; the browser's own // cache (which is what the save sheet reuses) still applies. if cc := w.Header().Get("Cache-Control"); cc != "private, max-age=31536000, immutable" { t.Fatalf("cache-control %q must not be public for a private gallery", cc) } bare := strings.SplitN(url, "?", 2)[0] if w := e.request(t, "GET", bare, "", nil); w.Code != 403 { t.Fatalf("anon download without token: want 403, got %d", w.Code) } if w := e.request(t, "GET", bare+"?size=original", "", nil); w.Code != 403 { t.Fatalf("anon original download without token: want 403, got %d", w.Code) } } // Photos processed before preview_size_bytes existed have no stored size. // The first gallery view measures them and writes the result back, so no // separate backfill command is needed for an existing library. func TestPreviewSizeBackfilledOnRead(t *testing.T) { e := setup(t) ctx := context.Background() admin := e.makeToken(t, uAdmin) g := createGallery(t, e, admin, map[string]any{"title": "Antigua", "visibility": "public"}) p := uploadPhoto(t, e, admin, g.ID, testJPEG(t)) if processQueue(t, e, p.ID).Status != "ready" { t.Fatal("processing failed") } // Rewind to what an pre-migration row looks like. if _, err := e.db.ExecContext(ctx, e.db.Rebind( "UPDATE photos_photos SET preview_size_bytes = NULL WHERE id = ?"), p.ID); err != nil { t.Fatal(err) } if before, err := e.db.GetPhoto(ctx, p.ID); err != nil { t.Fatal(err) } else if before.PreviewSizeBytes != 0 { t.Fatalf("setup: want an unmeasured row, got %d", before.PreviewSizeBytes) } served := e.request(t, "GET", "/api/photos/files/"+p.ID+"/download", "", nil).Body.Len() got := decode[galleryResp](t, e.request(t, "GET", "/api/photos/public/galleries/"+g.Slug, "", nil)) if got.Photos[0].PreviewSizeBytes != int64(served) { t.Fatalf("response size %d, served %d", got.Photos[0].PreviewSizeBytes, served) } // …and it was persisted, so the next view costs no Stat. after, err := e.db.GetPhoto(ctx, p.ID) if err != nil { t.Fatal(err) } if after.PreviewSizeBytes != int64(served) { t.Fatalf("stored size %d, served %d", after.PreviewSizeBytes, served) } } // Originals uploaded as HEIC or PNG must keep their real type and extension, // and a row with a missing/generic type must still go out as an image. func TestDownloadContentTypeNeverOctetStream(t *testing.T) { for _, tc := range []struct{ ct, name, want string }{ {"image/heic", "IMG_1.HEIC", "image/heic"}, {"image/png", "shot.png", "image/png"}, {"application/octet-stream", "IMG_2.heic", "image/heic"}, {"application/octet-stream", "scan.PNG", "image/png"}, {"", "photo.jpeg", "image/jpeg"}, {"", "", "image/jpeg"}, } { if got := imageContentType(tc.ct, tc.name); got != tc.want { t.Errorf("imageContentType(%q, %q) = %q, want %q", tc.ct, tc.name, got, tc.want) } } }