Files
OwnCord/Server/api/profile_handler_test.go
T
J3vbandClaude Fable 5 8579cb5d91 fix: batch of 25 correctness fixes across server and client (#1370)
* chore(workflows): raise subagent effort tiers (sonnet/haiku to xhigh, prove opus to high)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(voice): 6 defect(s) (OC-0098, OC-0004, OC-0005, OC-0006, OC-0007, OC-0020)

* fix(db): 1 defect(s) (OC-0096)

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

* fix(auth): 2 defect(s) (OC-0099, OC-0021)

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

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

* fix(api): 1 defect(s) (OC-0103)

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

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

* fix(api): 1 defect(s) (OC-0109)

* fix(api): 1 defect(s) (OC-0112)

* test(admin): compare restore bytes with bytes.Equal

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(voice): 2 defect(s) (OC-0095, OC-0014)

OC-0095: createRoom never called setE2EEEnabled(true), so the full ECDH/HKDF/AES-GCM key exchange completed but frames still reached the SFU in plaintext.

OC-0014: token refresh timer was 23h while the server mints LiveKit tokens with a 5-minute TTL, so any reconnect after minute 5 presented an expired token.

* fix(profile): 2 defect(s) (OC-0100, OC-0102)

* fix(service): 1 defect(s) (OC-0022)

Archived channels were only read-only for SendMessage/DeleteMessage. Edit, reaction, pin and purge sinks bypassed the check. Route every write sink through a shared requireChannelWritable gate.

* fix(api): 1 defect(s) (OC-0048)

* chore(workflows): correct stale model labels in bughunt-fix phase details

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

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

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

* test: fix two CI-only failures in the batch-4 test suite

The delete-account broadcast test now observes member_ban on a second
client's socket: the hub broadcasts and then force-disconnects the target,
so on a slow runner the close could beat the target's own copy of the
frame. The observer is also the party the event exists for.

The voice e2e mock now echoes the real joined channel id on voice_leave
(it hardcoded channel_id 0, which the dispatcher's channel-matched
self-leave teardown correctly ignores), and the rejoin test waits for the
mock's delayed echoes to settle before clicking the row again — clicking
inside the echo window toggled a leave instead of a join.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 14:49:27 +02:00

570 lines
19 KiB
Go

package api_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"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"
)
// buildProfileRouter returns a chi router with profile routes mounted.
func buildProfileRouter(database *db.DB) http.Handler {
r := chi.NewRouter()
limiter := auth.NewRateLimiter()
svc := service.New(database, limiter)
api.MountProfileRoutes(r, database, svc, nil, limiter, nil, nil)
return r
}
// profileCreateToken creates a user and session, returning the raw token.
func profileCreateToken(t *testing.T, database *db.DB, username string, roleID int) string {
t.Helper()
uid, err := database.CreateUser(context.Background(), username, mustHash(t), roleID)
if err != nil {
t.Fatalf("CreateUser(%s): %v", username, err)
}
token, err := auth.GenerateToken()
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
expiresAt := time.Now().Add(24 * time.Hour).UTC().Format("2006-01-02T15:04:05Z")
_, err = database.ExecContext(context.Background(),
"INSERT INTO sessions (user_id, token, device, ip_address, expires_at) VALUES (?, ?, ?, ?, ?)",
uid, auth.HashToken(token), "TestAgent", "127.0.0.1", expiresAt,
)
if err != nil {
t.Fatalf("insert session: %v", err)
}
return token
}
// mustHash returns a bcrypt hash of a standard test password.
func mustHash(t *testing.T) string {
t.Helper()
h, err := auth.HashPassword("securePass1")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
return h
}
// patchJSON sends a PATCH request with JSON body and auth token.
func patchJSON(t *testing.T, router http.Handler, path, token string, body any) *httptest.ResponseRecorder {
t.Helper()
raw, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPatch, path, bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
return rr
}
// putJSON sends a PUT request with JSON body and auth token.
func putJSON(t *testing.T, router http.Handler, path, token string, body any) *httptest.ResponseRecorder {
t.Helper()
raw, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPut, path, bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
return rr
}
// profileDelete sends a DELETE request with an auth token (no body).
func profileDelete(t *testing.T, router http.Handler, path, token string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodDelete, path, nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
return rr
}
// ─── PATCH /api/v1/users/me ──────────────────────────────────────────────────
func TestUpdateProfile_Success(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
token := profileCreateToken(t, database, "patchuser", 4)
rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]string{
"username": "newname",
"avatar": "https://example.com/av.png",
})
if rr.Code != http.StatusOK {
t.Errorf("status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
var resp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&resp)
if resp["username"] != "newname" {
t.Errorf("username = %v, want 'newname'", resp["username"])
}
}
func TestUpdateProfile_EmptyUsername(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
token := profileCreateToken(t, database, "emptyuser", 4)
rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]string{
"username": "",
})
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400; body = %s", rr.Code, rr.Body.String())
}
}
// OC-0100: a rename must canonicalize identically to how login looks the
// username up. A bare bluemonday sanitizer.Sanitize call HTML-escapes
// survivor punctuation instead of leaving it as typed, so a name with an
// apostrophe would be persisted as e.g. "O&#39;Brien" — unreachable by the
// literal name on the next login.
func TestUpdateProfile_UsernameWithApostropheIsNotEscaped(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
token := profileCreateToken(t, database, "apostropheuser", 4)
rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]string{
"username": "O'Brien",
})
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["username"] != "O'Brien" {
t.Errorf("username = %v, want %q (must not be HTML-escaped)", resp["username"], "O'Brien")
}
// The row itself must be reachable by the literal name — that is exactly
// what a future login looks up.
u, err := database.GetUserByUsername(context.Background(), "O'Brien")
if err != nil || u == nil {
t.Fatalf("GetUserByUsername(%q) = %v, %v — rename escaped the username and would lock the account out on next login", "O'Brien", u, err)
}
}
func TestUpdateProfile_UsernameTaken(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
profileCreateToken(t, database, "takenname", 4)
token := profileCreateToken(t, database, "wannatake", 4)
rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]string{
"username": "takenname",
})
if rr.Code != http.StatusConflict {
t.Errorf("status = %d, want 409; body = %s", rr.Code, rr.Body.String())
}
}
func TestUpdateProfile_Unauthorized(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
rr := patchJSON(t, router, "/api/v1/users/me", "badtoken", map[string]string{
"username": "hacker",
})
if rr.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rr.Code)
}
}
// ─── PUT /api/v1/users/me/password ──────────────────────────────────────────
func TestChangePassword_Success(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
token := profileCreateToken(t, database, "pwuser", 4)
rr := putJSON(t, router, "/api/v1/users/me/password", token, map[string]string{
"old_password": "securePass1",
"new_password": "newSecure2",
})
if rr.Code != http.StatusNoContent {
t.Errorf("status = %d, want 204; body = %s", rr.Code, rr.Body.String())
}
}
// BUG-108: Password change must revoke other sessions.
func TestChangePassword_RevokesOtherSessions(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
// Create user with two sessions.
token1 := profileCreateToken(t, database, "pw-revoke", 4)
user, _ := database.GetUserByUsername(context.Background(), "pw-revoke")
// Create a second session for the same user.
token2, _ := auth.GenerateToken()
expiresAt := time.Now().Add(24 * time.Hour).UTC().Format("2006-01-02T15:04:05Z")
_, _ = database.ExecContext(context.Background(),
"INSERT INTO sessions (user_id, token, device, ip_address, expires_at) VALUES (?, ?, ?, ?, ?)",
user.ID, auth.HashToken(token2), "OtherDevice", "10.0.0.1", expiresAt,
)
// Change password using token1.
rr := putJSON(t, router, "/api/v1/users/me/password", token1, map[string]string{
"old_password": "securePass1",
"new_password": "newSecure2",
})
if rr.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body = %s", rr.Code, rr.Body.String())
}
// token1 (current session) should still work.
sess1, _ := database.GetSessionByTokenHash(context.Background(), auth.HashToken(token1))
if sess1 == nil {
t.Error("current session should survive password change")
}
// token2 (other session) should be revoked.
sess2, _ := database.GetSessionByTokenHash(context.Background(), auth.HashToken(token2))
if sess2 != nil {
t.Error("other session should be revoked after password change")
}
}
func TestChangePassword_WrongOldPassword(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
token := profileCreateToken(t, database, "wrongpw", 4)
rr := putJSON(t, router, "/api/v1/users/me/password", token, map[string]string{
"old_password": "wrongPassword1",
"new_password": "newSecure2",
})
if rr.Code != http.StatusForbidden {
t.Errorf("status = %d, want 403; body = %s", rr.Code, rr.Body.String())
}
}
func TestChangePassword_WeakNewPassword(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
token := profileCreateToken(t, database, "weakpw", 4)
rr := putJSON(t, router, "/api/v1/users/me/password", token, map[string]string{
"old_password": "securePass1",
"new_password": "short",
})
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400; body = %s", rr.Code, rr.Body.String())
}
}
func TestChangePassword_SamePassword(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
token := profileCreateToken(t, database, "samepw", 4)
rr := putJSON(t, router, "/api/v1/users/me/password", token, map[string]string{
"old_password": "securePass1",
"new_password": "securePass1",
})
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400; body = %s", rr.Code, rr.Body.String())
}
}
// ─── GET /api/v1/users/me/sessions ──────────────────────────────────────────
func TestListSessions_Success(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
token := profileCreateToken(t, database, "sessuser", 4)
rr := getWithToken(t, router, "/api/v1/users/me/sessions", token)
if rr.Code != http.StatusOK {
t.Errorf("status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
var resp struct {
Sessions []map[string]any `json:"sessions"`
}
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Sessions) == 0 {
t.Error("expected at least 1 session (the current one)")
}
// Verify is_current flag is present.
found := false
for _, s := range resp.Sessions {
if isCurrent, ok := s["is_current"]; ok && isCurrent == true {
found = true
}
}
if !found {
t.Error("no session has is_current=true")
}
}
func TestListSessions_Unauthorized(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
rr := getWithToken(t, router, "/api/v1/users/me/sessions", "badtoken")
if rr.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rr.Code)
}
}
// ─── DELETE /api/v1/users/me/sessions/:id ───────────────────────────────────
func TestRevokeSession_Success(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
token := profileCreateToken(t, database, "revoke", 4)
// Create a second session to revoke.
user, _ := database.GetUserByUsername(context.Background(), "revoke")
secondSessID, _ := database.CreateSession(context.Background(), user.ID, auth.HashToken("second-tok"), "Firefox", "1.2.3.4")
rr := profileDelete(t, router, fmt.Sprintf("/api/v1/users/me/sessions/%d", secondSessID), token)
if rr.Code != http.StatusNoContent {
t.Errorf("status = %d, want 204; body = %s", rr.Code, rr.Body.String())
}
}
func TestRevokeSession_NotFound(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
token := profileCreateToken(t, database, "revokenf", 4)
rr := profileDelete(t, router, "/api/v1/users/me/sessions/99999", token)
if rr.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404; body = %s", rr.Code, rr.Body.String())
}
}
func TestRevokeSession_OtherUsersSession(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
token := profileCreateToken(t, database, "revokeother", 4)
// Create another user with a session.
otherUID, _ := database.CreateUser(context.Background(), "victim", mustHash(t), 4)
otherSessID, _ := database.CreateSession(context.Background(), otherUID, auth.HashToken("victim-tok"), "Safari", "9.8.7.6")
rr := profileDelete(t, router, fmt.Sprintf("/api/v1/users/me/sessions/%d", otherSessID), token)
if rr.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404 (should not reveal other user's session); body = %s", rr.Code, rr.Body.String())
}
}
func TestRevokeSession_CurrentSession(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
token := profileCreateToken(t, database, "revokeself", 4)
// Find the current session ID.
user, _ := database.GetUserByUsername(context.Background(), "revokeself")
sessions, _ := database.ListUserSessions(context.Background(), user.ID)
if len(sessions) == 0 {
t.Fatal("expected at least 1 session")
}
rr := profileDelete(t, router, fmt.Sprintf("/api/v1/users/me/sessions/%d", sessions[0].ID), token)
// Revoking own session is allowed.
if rr.Code != http.StatusNoContent {
t.Errorf("status = %d, want 204; body = %s", rr.Code, rr.Body.String())
}
}
// ─── PATCH /api/v1/users/me — identity_public_key (F3 voice E2EE TOFU) ───────
func TestUpdateProfile_PublishIdentityKey(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
token := profileCreateToken(t, database, "idkeyuser", 4)
key := "BPZ8bfkPz8B64iDeNtItYkEy0123456789abcdef+/=="
rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]string{
"username": "idkeyuser",
"identity_public_key": key,
})
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
u, err := database.GetUserByUsername(context.Background(), "idkeyuser")
if err != nil || u == nil {
t.Fatalf("GetUserByUsername: %v", err)
}
if u.IdentityPublicKey == nil || *u.IdentityPublicKey != key {
t.Errorf("IdentityPublicKey = %v, want %q", u.IdentityPublicKey, key)
}
}
func TestUpdateProfile_IdentityKeyOmitted_Unchanged(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
token := profileCreateToken(t, database, "idkeykeep", 4)
key := "a2VlcHRoaXNrZXk="
u, err := database.GetUserByUsername(context.Background(), "idkeykeep")
if err != nil || u == nil {
t.Fatalf("GetUserByUsername: %v", err)
}
if err := database.UpdateUserIdentityKey(context.Background(), u.ID, &key); err != nil {
t.Fatalf("UpdateUserIdentityKey: %v", err)
}
// PATCH without identity_public_key must not clear the stored key.
rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]string{
"username": "idkeykeep",
})
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
after, err := database.GetUserByID(context.Background(), u.ID)
if err != nil || after == nil {
t.Fatalf("GetUserByID: %v", err)
}
if after.IdentityPublicKey == nil || *after.IdentityPublicKey != key {
t.Errorf("IdentityPublicKey = %v, want %q (unchanged)", after.IdentityPublicKey, key)
}
}
func TestUpdateProfile_IdentityKeyInvalid(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
cases := []struct {
name string
key string
}{
{"not base64", "!!!not-base64!!!"},
{"url-safe alphabet", "abc-_def"},
{"too large", strings.Repeat("A", 132)},
{"empty", ""},
}
for i, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
token := profileCreateToken(t, database, fmt.Sprintf("idkeybad%d", i), 4)
rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]string{
"username": fmt.Sprintf("idkeybad%d", i),
"identity_public_key": tc.key,
})
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400; body = %s", rr.Code, rr.Body.String())
}
})
}
}
// ─── GET /api/v1/users/me/sessions with an API-token principal ────────────────
func TestListSessions_APITokenPrincipal(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
// One login session exists, but the caller authenticates with an API
// token (nil SessionKey). The sibling DELETE works for this principal;
// the list must too, with no session marked current.
_ = profileCreateToken(t, database, "apisessions", 4)
user, _ := database.GetUserByUsername(context.Background(), "apisessions")
if user == nil {
t.Fatal("user not found")
}
apiTok, err := auth.GenerateToken()
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
if _, err := database.CreateAPIToken(context.Background(), user.ID, auth.HashToken(apiTok), "ci", nil); err != nil {
t.Fatalf("CreateAPIToken: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/users/me/sessions", nil)
req.Header.Set("Authorization", "Bearer "+apiTok)
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("sessions list via API token: status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
var resp struct {
Sessions []struct {
ID int64 `json:"id"`
IsCurrent bool `json:"is_current"`
} `json:"sessions"`
}
_ = json.NewDecoder(rr.Body).Decode(&resp)
if len(resp.Sessions) != 1 {
t.Fatalf("sessions = %d, want 1", len(resp.Sessions))
}
if resp.Sessions[0].IsCurrent {
t.Error("API-token principal must not mark any session as current")
}
}
// ─── Rate-limit bucket isolation ─────────────────────────────────────────────
func TestRateLimit_ProfileUpdatesDoNotConsumePasswordBudget(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
token := profileCreateToken(t, database, "bucketuser", 4)
// Five profile PATCHes (limit 10/min). If the password endpoint shared
// the same bare-IP bucket, its 5/min budget would now read as exhausted
// with zero password attempts made.
for i := range 5 {
rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]string{
"username": "bucketuser",
})
if rr.Code == http.StatusTooManyRequests {
t.Fatalf("profile PATCH %d unexpectedly rate limited", i)
}
}
raw, _ := json.Marshal(map[string]string{
"old_password": "wrong-on-purpose",
"new_password": "NewSecurePass1!",
})
req := httptest.NewRequest(http.MethodPut, "/api/v1/users/me/password", bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code == http.StatusTooManyRequests {
t.Fatal("password change 429'd with zero password attempts made — unrelated endpoints share one rate-limit bucket")
}
}