package app import ( "bytes" "context" "encoding/json" "io/fs" "mime/multipart" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "time" "github.com/gin-gonic/gin" assets "galo" "galo/internal/auth" "galo/internal/config" "galo/internal/db" "galo/internal/models" "galo/internal/photos" "galo/internal/sse" "galo/internal/storage" "galo/internal/store" ) // newAvatarServer builds a full router over SQLite and FS storage, plus a valid // access token for a freshly created user. It needs no external database. func newAvatarServer(t *testing.T) (*gin.Engine, string, string, store.Repos) { t.Helper() gin.SetMode(gin.TestMode) migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite") if err != nil { t.Fatalf("migrations fs: %v", err) } path := filepath.Join(t.TempDir(), "meta.db") if err := db.MigrateSQLite(path, migFS); err != nil { t.Fatalf("migrate: %v", err) } database, err := db.OpenSQLite(path) if err != nil { t.Fatalf("open: %v", err) } t.Cleanup(func() { database.Close() }) repos := store.NewSQLite(database) signer, err := storage.NewSigner("secret", time.Hour, 10*time.Minute) if err != nil { t.Fatalf("signer: %v", err) } blob, err := storage.NewFS(t.TempDir(), signer) if err != nil { t.Fatalf("fs: %v", err) } tm := auth.NewTokenManager("test-secret", 15*time.Minute, time.Hour) srv := New(&config.Config{}, repos, func(context.Context) error { return nil }, tm, blob, sse.NewHub(1)) uid := auth.NewID() u := &models.User{ID: uid, Email: uid[:8] + "@test.local", PasswordHash: "x", Name: "Tester"} if err := repos.Auth.CreateUser(context.Background(), u); err != nil { t.Fatalf("create user: %v", err) } access, err := tm.GenerateAccess(uid, u.Email) if err != nil { t.Fatalf("access token: %v", err) } return srv.Router(), access, uid, repos } func multipartFile(t *testing.T, field, filename string, data []byte) (*bytes.Buffer, string) { t.Helper() var buf bytes.Buffer w := multipart.NewWriter(&buf) fw, err := w.CreateFormFile(field, filename) if err != nil { t.Fatalf("create form file: %v", err) } if _, err := fw.Write(data); err != nil { t.Fatalf("write form file: %v", err) } if err := w.Close(); err != nil { t.Fatalf("close writer: %v", err) } return &buf, w.FormDataContentType() } func doAuthed(r *gin.Engine, method, target string, body *bytes.Buffer, contentType, token string) *httptest.ResponseRecorder { var req *http.Request if body != nil { req = httptest.NewRequest(method, target, body) req.Header.Set("Content-Type", contentType) } else { req = httptest.NewRequest(method, target, nil) } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } rec := httptest.NewRecorder() r.ServeHTTP(rec, req) return rec } func avatarURLOf(t *testing.T, rec *httptest.ResponseRecorder) string { t.Helper() var body struct { User models.SelfUser `json:"user"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("decode body %q: %v", rec.Body.String(), err) } return body.User.AvatarURL } func TestAvatarWritesRequireAuth(t *testing.T) { r, _, _, _ := newAvatarServer(t) img := makeTestPNG(t) body, ct := multipartFile(t, "file", "a.png", img) if rec := doAuthed(r, http.MethodPost, "/api/auth/me/avatar", body, ct, ""); rec.Code != http.StatusUnauthorized { t.Fatalf("upload without token = %d, want 401", rec.Code) } if rec := doAuthed(r, http.MethodDelete, "/api/auth/me/avatar", nil, "", ""); rec.Code != http.StatusUnauthorized { t.Fatalf("delete without token = %d, want 401", rec.Code) } } func TestAvatarUploadServeReplaceRemove(t *testing.T) { r, token, _, _ := newAvatarServer(t) img := makeTestPNG(t) // Upload. body, ct := multipartFile(t, "file", "a.png", img) rec := doAuthed(r, http.MethodPost, "/api/auth/me/avatar", body, ct, token) if rec.Code != http.StatusOK { t.Fatalf("upload = %d body=%s", rec.Code, rec.Body.String()) } first := avatarURLOf(t, rec) if first == "" || !strings.HasPrefix(first, "/api/avatars/") { t.Fatalf("avatar_url = %q", first) } // The image loader fetches without an Authorization header: the read route // is public and returns a WebP blob. rec = doAuthed(r, http.MethodGet, first, nil, "", "") if rec.Code != http.StatusOK { t.Fatalf("serve = %d body=%s", rec.Code, rec.Body.String()) } if !bytes.HasPrefix(rec.Body.Bytes(), []byte("RIFF")) || !bytes.Contains(rec.Body.Bytes()[:16], []byte("WEBP")) { t.Fatalf("served body is not WebP") } if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") { t.Fatalf("cache-control = %q", cc) } // A non-image upload is rejected and does not change the avatar. body, ct = multipartFile(t, "file", "a.txt", []byte("not an image")) rec = doAuthed(r, http.MethodPost, "/api/auth/me/avatar", body, ct, token) if rec.Code != http.StatusBadRequest { t.Fatalf("bad image = %d, want 400", rec.Code) } // Replace changes the version segment so caches invalidate. body, ct = multipartFile(t, "file", "b.png", img) rec = doAuthed(r, http.MethodPost, "/api/auth/me/avatar", body, ct, token) if rec.Code != http.StatusOK { t.Fatalf("replace = %d body=%s", rec.Code, rec.Body.String()) } second := avatarURLOf(t, rec) if second == "" || second == first { t.Fatalf("avatar_url did not change on replace: %q", second) } // Remove clears the avatar and the served URL 404s. if rec := doAuthed(r, http.MethodDelete, "/api/auth/me/avatar", nil, "", token); rec.Code != http.StatusOK { t.Fatalf("remove = %d body=%s", rec.Code, rec.Body.String()) } else if url := avatarURLOf(t, rec); url != "" { t.Fatalf("avatar_url not cleared: %q", url) } if rec := doAuthed(r, http.MethodGet, second, nil, "", token); rec.Code != http.StatusNotFound { t.Fatalf("serve after remove = %d, want 404", rec.Code) } } func TestServeAvatarMissingUser(t *testing.T) { r, token, _, _ := newAvatarServer(t) rec := doAuthed(r, http.MethodGet, "/api/avatars/does-not-exist/x", nil, "", token) if rec.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404", rec.Code) } } func TestAvatarAppearsInUserViews(t *testing.T) { ctx := context.Background() r, token, uid, repos := newAvatarServer(t) body, ct := multipartFile(t, "file", "a.png", makeTestPNG(t)) rec := doAuthed(r, http.MethodPost, "/api/auth/me/avatar", body, ct, token) if rec.Code != http.StatusOK { t.Fatalf("upload = %d body=%s", rec.Code, rec.Body.String()) } url := avatarURLOf(t, rec) // The self profile carries the avatar. rec = doAuthed(r, http.MethodGet, "/api/auth/me", nil, "", token) if me := avatarURLOf(t, rec); me != url { t.Fatalf("auth/me avatar_url = %q, want %q", me, url) } // Public representations carry it too: a member with an avatar exposes the // URL, a member without one omits it. album := &models.Album{ID: auth.NewID(), OwnerID: uid, Title: "A"} if err := repos.Albums.Create(ctx, album); err != nil { t.Fatalf("create album: %v", err) } if err := repos.Members.Add(ctx, album.ID, uid, "editor", nil); err != nil { t.Fatalf("add owner member: %v", err) } other := &models.User{ID: auth.NewID(), Email: auth.NewID()[:8] + "@test.local", PasswordHash: "x", Name: "Other"} if err := repos.Auth.CreateUser(ctx, other); err != nil { t.Fatalf("create other user: %v", err) } if err := repos.Members.Add(ctx, album.ID, other.ID, "viewer", nil); err != nil { t.Fatalf("add other member: %v", err) } rec = doAuthed(r, http.MethodGet, "/api/albums/"+album.ID+"/members", nil, "", token) if rec.Code != http.StatusOK { t.Fatalf("members = %d body=%s", rec.Code, rec.Body.String()) } var mb struct { Members []models.AlbumMember `json:"members"` } if err := json.Unmarshal(rec.Body.Bytes(), &mb); err != nil { t.Fatalf("decode members: %v", err) } seen := map[string]string{} for _, m := range mb.Members { seen[m.User.ID] = m.User.AvatarURL } if seen[uid] != url { t.Fatalf("member avatar_url = %q, want %q", seen[uid], url) } if seen[other.ID] != "" { t.Fatalf("member without avatar has avatar_url = %q", seen[other.ID]) } // Comments carry the commenter's avatar too. photo := &models.Photo{ID: auth.NewID(), AlbumID: album.ID, UploaderID: uid, OriginalFilename: "p.jpg"} obj := &photos.Object{ID: auth.NewID(), StorageKey: "objects/" + auth.NewID(), State: photos.ObjectPending} if err := repos.Photos.CreateWithOriginal(ctx, photo, obj); err != nil { t.Fatalf("create photo: %v", err) } if err := repos.Photos.ActivateObject(ctx, obj.ID); err != nil { t.Fatalf("activate photo object: %v", err) } rec = doAuthed(r, http.MethodPost, "/api/photos/"+photo.ID+"/comments", bytes.NewBufferString(`{"text":"hi"}`), "application/json", token) if rec.Code != http.StatusCreated { t.Fatalf("comment = %d body=%s", rec.Code, rec.Body.String()) } rec = doAuthed(r, http.MethodGet, "/api/photos/"+photo.ID+"/comments", nil, "", token) if rec.Code != http.StatusOK { t.Fatalf("list comments = %d body=%s", rec.Code, rec.Body.String()) } var cb struct { Comments []models.Comment `json:"comments"` } if err := json.Unmarshal(rec.Body.Bytes(), &cb); err != nil { t.Fatalf("decode comments: %v", err) } if len(cb.Comments) != 1 || cb.Comments[0].User.AvatarURL != url { t.Fatalf("comment avatar_url = %+v, want %q", cb.Comments, url) } }