mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* 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>
370 lines
14 KiB
Go
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)
|
|
}
|