package api_test import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "os" "strings" "testing" "github.com/go-chi/chi/v5" "github.com/owncord/server/api" "github.com/owncord/server/auth" "github.com/owncord/server/db" "github.com/owncord/server/service" "github.com/owncord/server/storage" "github.com/owncord/server/ws" ) // Avatar upload. The interesting properties are the ones a URL-only avatar // field never had to answer: what the server accepts as an image, where the // bytes end up, and — the one that would otherwise be a privacy bug — who is // allowed to fetch them back. // buildAvatarRouter mounts the profile routes (with storage, so the upload // route exists) and the upload routes (so the file can be fetched back) // against one database. func buildAvatarRouter(database *db.DB, store *storage.Storage) http.Handler { r := chi.NewRouter() limiter := auth.NewRateLimiter() svc := service.New(database, limiter) api.MountProfileRoutes(r, database, svc, store, limiter, nil, nil) api.MountUploadRoutes(r, database, store, limiter, nil, svc.Permissions) return r } func doAvatarUpload(t *testing.T, router http.Handler, token, filename string, content []byte) *httptest.ResponseRecorder { t.Helper() body, contentType := makeMultipartFile(t, "file", filename, content) req := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/avatar", body) req.Header.Set("Content-Type", contentType) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } req.RemoteAddr = "127.0.0.1:9999" rr := httptest.NewRecorder() router.ServeHTTP(rr, req) return rr } func TestUploadAvatar_StoresAndSetsAvatarURL(t *testing.T) { database := newUploadTestDB(t) store := newUploadTestStorage(t) router := buildAvatarRouter(database, store) token := uploadCreateToken(t, database, "pfp_owner", 4) rr := doAvatarUpload(t, router, token, "me.png", makePNGBytes(t, 64, 64)) if rr.Code != http.StatusCreated { t.Fatalf("status = %d, want 201; body = %s", rr.Code, rr.Body.String()) } var resp map[string]any if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { t.Fatalf("decode: %v", err) } id, _ := resp["id"].(string) if id == "" { t.Fatal("response carried no file id") } if resp["url"] != service.AvatarFileURL(id) { t.Errorf("url = %v, want %q", resp["url"], service.AvatarFileURL(id)) } if resp["mime"] != "image/png" { t.Errorf("mime = %v, want image/png", resp["mime"]) } // The column must now point at the served file — that is what makes the // avatar both renderable and readable. user, err := database.GetUserByUsername(context.Background(), "pfp_owner") if err != nil || user == nil { t.Fatalf("GetUserByUsername: %v", err) } if user.Avatar == nil || *user.Avatar != service.AvatarFileURL(id) { t.Fatalf("stored avatar = %v, want %q", user.Avatar, service.AvatarFileURL(id)) } } func TestUploadAvatar_IsReadableByOtherUsersWhileInUse(t *testing.T) { database := newUploadTestDB(t) store := newUploadTestStorage(t) router := buildAvatarRouter(database, store) ownerToken := uploadCreateToken(t, database, "avatar_owner", 4) otherToken := uploadCreateToken(t, database, "avatar_peer", 4) rr := doAvatarUpload(t, router, ownerToken, "me.png", makePNGBytes(t, 32, 32)) if rr.Code != http.StatusCreated { t.Fatalf("upload status = %d; body = %s", rr.Code, rr.Body.String()) } var resp map[string]any _ = json.NewDecoder(rr.Body).Decode(&resp) id, _ := resp["id"].(string) // An unlinked attachment is normally uploader-only. An avatar has to be // visible to the people who see the messages it sits beside. if got := doServeFile(t, router, id, otherToken, nil); got.Code != http.StatusOK { t.Fatalf("peer fetch status = %d, want 200; body = %s", got.Code, got.Body.String()) } // Replacing the avatar revokes that: the old file goes back to being a // private unlinked attachment. rr2 := doAvatarUpload(t, router, ownerToken, "me2.png", makePNGBytes(t, 33, 33)) if rr2.Code != http.StatusCreated { t.Fatalf("second upload status = %d; body = %s", rr2.Code, rr2.Body.String()) } if got := doServeFile(t, router, id, otherToken, nil); got.Code != http.StatusForbidden { t.Errorf("peer fetch of replaced avatar = %d, want 403", got.Code) } // The uploader can still reach their own old file. if got := doServeFile(t, router, id, ownerToken, nil); got.Code != http.StatusOK { t.Errorf("uploader fetch of replaced avatar = %d, want 200", got.Code) } } func TestUploadAvatar_RejectsNonImageAndOversizedDimensions(t *testing.T) { database := newUploadTestDB(t) store := newUploadTestStorage(t) router := buildAvatarRouter(database, store) token := uploadCreateToken(t, database, "avatar_bad", 4) // Sniffed from the bytes, never from the filename or the client's header. if rr := doAvatarUpload(t, router, token, "me.png", []byte("this is plain text, not a PNG")); rr.Code != http.StatusBadRequest { t.Errorf("text-as-png status = %d, want 400", rr.Code) } // GIF is a real image and still refused: an animated avatar in every // message row is a distraction the renderer cannot opt out of. gif := []byte("GIF89a") if rr := doAvatarUpload(t, router, token, "me.gif", gif); rr.Code != http.StatusBadRequest { t.Errorf("gif status = %d, want 400", rr.Code) } // Too many pixels for any surface that renders it. if rr := doAvatarUpload(t, router, token, "huge.png", makePNGBytes(t, 2000, 100)); rr.Code != http.StatusBadRequest { t.Errorf("oversized status = %d, want 400", rr.Code) } // None of the rejections may have moved the column. user, _ := database.GetUserByUsername(context.Background(), "avatar_bad") if user != nil && user.Avatar != nil && *user.Avatar != "" { t.Errorf("a rejected upload set the avatar to %q", *user.Avatar) } } func TestUploadAvatar_RequiresAuthAndAFile(t *testing.T) { database := newUploadTestDB(t) store := newUploadTestStorage(t) router := buildAvatarRouter(database, store) token := uploadCreateToken(t, database, "avatar_auth", 4) if rr := doAvatarUpload(t, router, "", "me.png", makePNGBytes(t, 8, 8)); rr.Code != http.StatusUnauthorized { t.Errorf("anonymous status = %d, want 401", rr.Code) } // Right form, wrong field name. body, contentType := makeMultipartFile(t, "avatar", "me.png", makePNGBytes(t, 8, 8)) req := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/avatar", body) req.Header.Set("Content-Type", contentType) req.Header.Set("Authorization", "Bearer "+token) rr := httptest.NewRecorder() router.ServeHTTP(rr, req) if rr.Code != http.StatusBadRequest { t.Errorf("wrong field status = %d, want 400", rr.Code) } } // TestUploadAvatar_StorageErrorDoesNotLeakPath pins the same contract // upload_handler.go's safeStorageErrorMessage enforces for the plain-file // upload route: a storage.Save failure (disk full, permission change, // read-only mount) must never hand the client the server's absolute // storage path. handleUploadAvatar currently forwards saveErr verbatim. func TestUploadAvatar_StorageErrorDoesNotLeakPath(t *testing.T) { database := newUploadTestDB(t) dir := t.TempDir() store, err := storage.New(dir, 10) if err != nil { t.Fatalf("storage.New: %v", err) } router := buildAvatarRouter(database, store) token := uploadCreateToken(t, database, "avatar_leakuser", 4) // Remove the storage directory out from under the already-constructed // Storage so Save's os.Create fails — this is what a disk-full, // permission-change, or read-only-mount failure looks like from the // handler's point of view: a storage-layer error surfaces at Save time. if err := os.RemoveAll(dir); err != nil { t.Fatalf("RemoveAll: %v", err) } rr := doAvatarUpload(t, router, token, "me.png", makePNGBytes(t, 32, 32)) // Server-side filesystem failures are 507 (storage.ErrIO) so they are // distinguishable from bad uploads; the no-leak contract is unchanged. if rr.Code != http.StatusInsufficientStorage { t.Fatalf("status = %d, want 507; body: %s", rr.Code, rr.Body.String()) } var resp map[string]any if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { t.Fatalf("decode: %v", err) } message, _ := resp["message"].(string) if strings.Contains(message, dir) { t.Fatalf("response message leaks the absolute storage path: %q", message) } if strings.ContainsAny(message, `/\`) { t.Fatalf("response message looks like it contains a filesystem path: %q", message) } } func TestUploadAvatar_NotMountedWithoutStorage(t *testing.T) { database := newUploadTestDB(t) r := chi.NewRouter() limiter := auth.NewRateLimiter() api.MountProfileRoutes(r, database, service.New(database, limiter), nil, limiter, nil, nil) token := uploadCreateToken(t, database, "no_storage", 4) if rr := doAvatarUpload(t, r, token, "me.png", makePNGBytes(t, 8, 8)); rr.Code == http.StatusCreated { t.Error("avatar upload must not be served when there is no storage backend") } } // ─── PATCH /users/me: display name and about ───────────────────────────────── func TestUpdateProfile_SetsDisplayNameAndAbout(t *testing.T) { database := newAuthTestDB(t) router := buildProfileRouter(database) token := profileCreateToken(t, database, "bio_user", 4) rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]any{ "username": "bio_user", "display_name": "Bio User", "about": "writes tests", }) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body = %s", rr.Code, rr.Body.String()) } var resp map[string]any _ = json.NewDecoder(rr.Body).Decode(&resp) if resp["display_name"] != "Bio User" { t.Errorf("display_name = %v", resp["display_name"]) } if resp["about"] != "writes tests" { t.Errorf("about = %v", resp["about"]) } // Omitting them leaves them alone. rr = patchJSON(t, router, "/api/v1/users/me", token, map[string]any{"username": "bio_user"}) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } resp = map[string]any{} _ = json.NewDecoder(rr.Body).Decode(&resp) if resp["display_name"] != "Bio User" || resp["about"] != "writes tests" { t.Errorf("a username-only PATCH cleared fields: %v / %v", resp["display_name"], resp["about"]) } // An explicit empty string clears them. rr = patchJSON(t, router, "/api/v1/users/me", token, map[string]any{ "username": "bio_user", "display_name": "", "about": "", }) resp = map[string]any{} _ = json.NewDecoder(rr.Body).Decode(&resp) if resp["display_name"] != nil || resp["about"] != nil { t.Errorf("expected cleared, got %v / %v", resp["display_name"], resp["about"]) } } func TestUpdateProfile_RejectsBadDisplayName(t *testing.T) { database := newAuthTestDB(t) router := buildProfileRouter(database) token := profileCreateToken(t, database, "dn_user", 4) // A right-to-left override makes a name render as something other than // what it says — the same spoof auth.ValidateUsername rejects. rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]any{ "username": "dn_user", "display_name": "ada\u202egnp.exe", }) if rr.Code != http.StatusBadRequest { t.Errorf("bidi-override display_name status = %d, want 400", rr.Code) } rr = patchJSON(t, router, "/api/v1/users/me", token, map[string]any{ "username": "dn_user", "display_name": strings.Repeat("a", 33), }) if rr.Code != http.StatusBadRequest { t.Errorf("overlong display_name status = %d, want 400", rr.Code) } rr = patchJSON(t, router, "/api/v1/users/me", token, map[string]any{ "username": "dn_user", "about": strings.Repeat("b", 301), }) if rr.Code != http.StatusBadRequest { t.Errorf("overlong about status = %d, want 400", rr.Code) } } func TestUpdateProfile_BroadcastCarriesEveryProfileField(t *testing.T) { database := newAuthTestDB(t) r := chi.NewRouter() limiter := auth.NewRateLimiter() spy := &userUpdateSpy{} api.MountProfileRoutes(r, database, service.New(database, limiter), nil, limiter, nil, spy) token := profileCreateToken(t, database, "bc_user", 4) rr := patchJSON(t, r, "/api/v1/users/me", token, map[string]any{ "username": "bc_user", "display_name": "Broadcaster", "about": "hi", }) if rr.Code != http.StatusOK { t.Fatalf("status = %d; body = %s", rr.Code, rr.Body.String()) } if len(spy.got) != 1 { t.Fatalf("broadcasts = %d, want 1", len(spy.got)) } u := spy.got[0] // user_update replaces the client's copy wholesale, so a broadcast that // omits a field would silently blank it everywhere. if u.DisplayName == nil || *u.DisplayName != "Broadcaster" { t.Errorf("broadcast display_name = %v", u.DisplayName) } if u.About == nil || *u.About != "hi" { t.Errorf("broadcast about = %v", u.About) } if u.Username != "bc_user" { t.Errorf("broadcast username = %q", u.Username) } } func TestLogout_ClearsCustomStatus(t *testing.T) { database := newAuthTestDB(t) router := buildAuthRouter(database, auth.NewRateLimiter()) token := profileCreateToken(t, database, "logout_status", 4) user, err := database.GetUserByUsername(context.Background(), "logout_status") if err != nil || user == nil { t.Fatalf("GetUserByUsername: %v", err) } text := "in a meeting" if err := database.UpdateUserCustomStatus(context.Background(), user.ID, &text); err != nil { t.Fatalf("UpdateUserCustomStatus: %v", err) } req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", bytes.NewReader(nil)) req.Header.Set("Authorization", "Bearer "+token) req.RemoteAddr = "127.0.0.1:9999" rr := httptest.NewRecorder() router.ServeHTTP(rr, req) if rr.Code != http.StatusNoContent { t.Fatalf("logout status = %d, want 204; body = %s", rr.Code, rr.Body.String()) } after, _ := database.GetUserByID(context.Background(), user.ID) if after.CustomStatus != nil { t.Errorf("custom_status = %q, want cleared on logout", *after.CustomStatus) } } // userUpdateSpy captures the user_update broadcasts the profile routes emit. type userUpdateSpy struct { got []ws.UserUpdate } func (s *userUpdateSpy) BroadcastUserUpdate(u ws.UserUpdate) { s.got = append(s.got, u) }