Files
OwnCord/Server/api/avatar_handler_test.go
T
J3vbandClaude b8b7a2a1f9 fix: correctness fixes across LiveKit voice, client session and transport paths (#1374)
* fix: enhance bugfix workflow documentation with detailed clustering and staging instructions

* fix(voice): 6 defect(s) (OC-0001, OC-0006, OC-0009, OC-0010, OC-0015, OC-0029)

* fix(voice): 1 defect(s) (OC-0005)

* fix(client): 1 defect(s) (OC-0007)

* fix(client): 1 defect(s) (OC-0011)

* fix(client): 1 defect(s) (OC-0012)

* fix(admin): 1 defect(s) (OC-0013)

* fix(client): 3 defect(s) (OC-0014, OC-0024, OC-0031)

* fix(voice): 1 defect(s) (OC-0018)

* fix(voice): 1 defect(s) (OC-0019)

* fix(client): 1 defect(s) (OC-0021)

* fix(client): 1 defect(s) (OC-0025)

* fix(ws): 1 defect(s) (OC-0026)

* fix(client): 1 defect(s) (OC-0027)

* fix(client): 1 defect(s) (OC-0028)

* fix(identity): 1 defect(s) (OC-0030)

* fix(voice): 1 defect(s) (OC-0016)

* fix(client): 2 defect(s) (OC-0002, OC-0020)

OC-0002: chain offer handling behind the announce chain so an offer that
arrives immediately behind its sender's announce is not dropped as an
unknown peer.

OC-0020: retire a departing peer's ECDH key on participant-left so a
replayed pre-leave announce cannot overwrite the fresh key they rejoined
with.

* fix(voice): 1 defect(s) (OC-0008)

handleVoiceJoin handed the client its LiveKit token before checking whether
the join had been superseded by a concurrent eviction (moderator kick/move,
the CONNECT_VOICE revocation sweep, CleanupVoiceForChannel). Those evictors
delete the voice_states row, clear the client's in-memory state, and call
RemoveParticipant — which no-ops because the join has not reached the SFU
yet. The client was left holding a live 5-minute RoomJoin credential for a
membership the server had just torn down.

Re-check the client's voice state immediately after GenerateToken and
withhold the credential if the join was superseded, with a best-effort
RemoveParticipant to match every other eviction path.

* fix(ws): 2 defect(s) (OC-0017, OC-0022)

OC-0017: sweepStaleVoiceStates re-checks the live client immediately before
deleting a snapshotted-stale voice_states row. voice_join commits the row
before calling c.setVoiceState, so a join that lands inside that window was
snapshotted as a ghost and had its just-committed row deleted, leaving the
client in voice in memory with no DB row.

OC-0022: CleanupVoiceForChannel resolves its voice_leave audience with a
variant of channelReadAudience that skips the archived short-circuit. Both
production callers archive the channel before evicting, so the plain
resolver always returned an empty audience and only the evicted
participants learned the call ended.

* fix(voice): 1 defect(s) (OC-0023)

Camera and screenshare now draw from the same per-channel voice_max_video
budget. handleVoiceScreenshareV2 performed no cap check at all, and the
camera gate's slot-count subquery counted only `camera = 1` rows, so a
screensharing occupant was invisible to it. Both gates now count
`camera = 1 OR screenshare = 1` via a shared enableVideoSlot helper.

* fix(client): 2 defect(s) (OC-0032, OC-0033)

OC-0033: voice_disconnected staleness guard swallowed the kick toast when
the sibling voice_leave had already cleared currentChannelId. Treat a
cleared store as not-stale.

OC-0032: VIDEO_LIMIT rollback assumed the camera, tearing down a working
camera and leaving refused screen tracks published. Correlate by envelope
id and roll back the kind that was actually refused.

* fix(voice): 1 defect(s) (OC-0034)

* fix(client): 1 defect(s) (OC-0035)

A superseded video-enable id makes rollbackPendingVideo return undefined.
The dispatcher's ternary treated undefined as "not screen" and called
disableCamera(), tearing down a working camera the user never touched.
Return early instead: undefined means there is nothing to roll back.

* fix(voice): 1 defect(s) (OC-0036)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-15 12:57:51 +02:00

370 lines
14 KiB
Go

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))
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; 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)
}