- backend: user_avatar_objects binding (PG+SQLite), GC preserves avatar blobs, avatar service (EXIF-stripping WebP 128/512), public avatar read route - web: avatar upload/crop on profile, round avatar in header/members/comments/author - android: avatar upload/crop, round avatar on profile and albums header - openspec: add-user-avatar proposal/design/specs/tasks
278 lines
9.1 KiB
Go
278 lines
9.1 KiB
Go
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)
|
|
}
|
|
}
|