From b4e15e1234b1b9a305aa90e850a49c8a61f64807 Mon Sep 17 00:00:00 2001 From: jevb Date: Wed, 1 Apr 2026 11:37:55 +0200 Subject: [PATCH] feat: add user profile management endpoints (T-195) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PATCH /api/v1/users/me — update username/avatar PUT /api/v1/users/me/password — change password with old pw verification GET /api/v1/users/me/sessions — list active sessions (single SQL query) DELETE /api/v1/users/me/sessions/:id — revoke session with ownership check New files: profile_handler.go, profile_queries.go + tests for both. All endpoints follow existing writeJSON/errorResponse patterns. --- Server/api/profile_handler.go | 313 +++++++++++++++++++++++++++ Server/api/profile_handler_test.go | 332 +++++++++++++++++++++++++++++ Server/db/profile_queries.go | 94 ++++++++ Server/db/profile_queries_test.go | 172 +++++++++++++++ 4 files changed, 911 insertions(+) create mode 100644 Server/api/profile_handler.go create mode 100644 Server/api/profile_handler_test.go create mode 100644 Server/db/profile_queries.go create mode 100644 Server/db/profile_queries_test.go diff --git a/Server/api/profile_handler.go b/Server/api/profile_handler.go new file mode 100644 index 00000000..dfa8f46e --- /dev/null +++ b/Server/api/profile_handler.go @@ -0,0 +1,313 @@ +package api + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/owncord/server/auth" + "github.com/owncord/server/db" +) + +// ─── Request / Response types ──────────────────────────────────────────────── + +// updateProfileRequest is the JSON body for PATCH /api/v1/users/me. +type updateProfileRequest struct { + Username string `json:"username"` + Avatar *string `json:"avatar"` +} + +// changePasswordRequest is the JSON body for PUT /api/v1/users/me/password. +type changePasswordRequest struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` +} + +// sessionResponse is the JSON shape for a single session in list responses. +type sessionResponse struct { + ID int64 `json:"id"` + Device string `json:"device"` + IP string `json:"ip"` + CreatedAt string `json:"created_at"` + LastUsed string `json:"last_used"` + IsCurrent bool `json:"is_current"` +} + +// sessionsListResponse is the JSON envelope for GET /api/v1/users/me/sessions. +type sessionsListResponse struct { + Sessions []sessionResponse `json:"sessions"` +} + +// ─── Route mounting ────────────────────────────────────────────────────────── + +// MountProfileRoutes registers user profile management endpoints. +// All routes require authentication. trustedProxies is used for rate limiting. +func MountProfileRoutes(r chi.Router, database *db.DB, limiter *auth.RateLimiter, trustedProxies []string) { + r.Route("/api/v1/users/me", func(r chi.Router) { + r.Use(AuthMiddleware(database)) + + r.Patch("/", handleUpdateProfile(database)) + + r.With(RateLimitMiddleware(limiter, profilePasswordRateLimitPerMinute, time.Minute, trustedProxies)). + Put("/password", handleChangePassword(database)) + + r.Get("/sessions", handleListSessions(database)) + r.Delete("/sessions/{id}", handleRevokeSession(database)) + }) +} + +// ─── Handlers ──────────────────────────────────────────────────────────────── + +// handleUpdateProfile processes PATCH /api/v1/users/me. +func handleUpdateProfile(database *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user, ok := r.Context().Value(UserKey).(*db.User) + if !ok || user == nil { + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", + Message: "not authenticated", + }) + return + } + + var req updateProfileRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INVALID_INPUT", + Message: "malformed request body", + }) + return + } + + req.Username = strings.TrimSpace(sanitizer.Sanitize(req.Username)) + + if req.Username == "" { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INVALID_INPUT", + Message: "username is required", + }) + return + } + + if err := auth.ValidateUsername(req.Username); err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INVALID_INPUT", + Message: err.Error(), + }) + return + } + + // Sanitize avatar if provided. + if req.Avatar != nil { + trimmed := strings.TrimSpace(sanitizer.Sanitize(*req.Avatar)) + req.Avatar = &trimmed + } + + if err := database.UpdateUserProfile(user.ID, req.Username, req.Avatar); err != nil { + if db.IsUniqueConstraintError(err) { + writeJSON(w, http.StatusConflict, errorResponse{ + Error: "CONFLICT", + Message: "username is already taken", + }) + return + } + slog.Error("UpdateUserProfile failed", "err", err, "user_id", user.ID) + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "INTERNAL_ERROR", + Message: "failed to update profile", + }) + return + } + + // Re-fetch user for the response. + updated, err := database.GetUserByID(user.ID) + if err != nil || updated == nil { + slog.Error("failed to fetch user after profile update", "user_id", user.ID, "error", err) + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "INTERNAL_ERROR", + Message: "profile updated but fetch failed", + }) + return + } + + slog.Info("profile updated", "user_id", user.ID, "new_username", req.Username) + _ = database.LogAudit(user.ID, "profile_update", "user", user.ID, "profile updated") + + writeJSON(w, http.StatusOK, toUserResponse(updated)) + } +} + +// handleChangePassword processes PUT /api/v1/users/me/password. +func handleChangePassword(database *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user, ok := r.Context().Value(UserKey).(*db.User) + if !ok || user == nil { + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", + Message: "not authenticated", + }) + return + } + + var req changePasswordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INVALID_INPUT", + Message: "malformed request body", + }) + return + } + + if req.OldPassword == "" || req.NewPassword == "" { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INVALID_INPUT", + Message: "old_password and new_password are required", + }) + return + } + + // Verify old password using constant-time bcrypt comparison. + if !auth.CheckPassword(user.PasswordHash, req.OldPassword) { + writeJSON(w, http.StatusForbidden, errorResponse{ + Error: "FORBIDDEN", + Message: "incorrect password", + }) + return + } + + // Reject same old/new password. + if req.OldPassword == req.NewPassword { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INVALID_INPUT", + Message: "new password must be different from old password", + }) + return + } + + // Validate new password strength. + if err := auth.ValidatePasswordStrength(req.NewPassword); err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INVALID_INPUT", + Message: err.Error(), + }) + return + } + + // Hash new password. + hash, err := auth.HashPassword(req.NewPassword) + if err != nil { + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "INTERNAL_ERROR", + Message: "failed to process password change", + }) + return + } + + if err := database.UpdateUserPassword(user.ID, hash); err != nil { + slog.Error("UpdateUserPassword failed", "err", err, "user_id", user.ID) + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "INTERNAL_ERROR", + Message: "failed to update password", + }) + return + } + + slog.Info("password changed", "user_id", user.ID) + _ = database.LogAudit(user.ID, "password_change", "user", user.ID, "password changed") + + w.WriteHeader(http.StatusNoContent) + } +} + +// handleListSessions processes GET /api/v1/users/me/sessions. +func handleListSessions(database *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user, ok := r.Context().Value(UserKey).(*db.User) + if !ok || user == nil { + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", + Message: "not authenticated", + }) + return + } + + sess, ok := r.Context().Value(SessionKey).(*db.Session) + if !ok || sess == nil { + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", + Message: "not authenticated", + }) + return + } + + sessions, err := database.ListUserSessions(user.ID) + if err != nil { + slog.Error("ListUserSessions failed", "err", err, "user_id", user.ID) + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "INTERNAL_ERROR", + Message: "failed to list sessions", + }) + return + } + + resp := sessionsListResponse{ + Sessions: make([]sessionResponse, 0, len(sessions)), + } + for _, s := range sessions { + resp.Sessions = append(resp.Sessions, sessionResponse{ + ID: s.ID, + Device: s.Device, + IP: s.IP, + CreatedAt: s.CreatedAt, + LastUsed: s.LastUsed, + IsCurrent: s.ID == sess.ID, + }) + } + + writeJSON(w, http.StatusOK, resp) + } +} + +// handleRevokeSession processes DELETE /api/v1/users/me/sessions/{id}. +func handleRevokeSession(database *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user, ok := r.Context().Value(UserKey).(*db.User) + if !ok || user == nil { + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", + Message: "not authenticated", + }) + return + } + + sessionID, ok := parseIDParam(w, r, "id") + if !ok { + return + } + + if err := database.DeleteSessionByID(sessionID, user.ID); err != nil { + if errors.Is(err, db.ErrNotFound) { + writeJSON(w, http.StatusNotFound, errorResponse{ + Error: "NOT_FOUND", + Message: "session not found", + }) + return + } + slog.Error("DeleteSessionByID failed", "err", err, "session_id", sessionID, "user_id", user.ID) + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "INTERNAL_ERROR", + Message: "failed to revoke session", + }) + return + } + + slog.Info("session revoked", "user_id", user.ID, "session_id", sessionID) + _ = database.LogAudit(user.ID, "session_revoke", "session", sessionID, "session revoked") + + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/Server/api/profile_handler_test.go b/Server/api/profile_handler_test.go new file mode 100644 index 00000000..9206c274 --- /dev/null +++ b/Server/api/profile_handler_test.go @@ -0,0 +1,332 @@ +package api_test + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/owncord/server/api" + "github.com/owncord/server/auth" + "github.com/owncord/server/db" +) + +// buildProfileRouter returns a chi router with profile routes mounted. +func buildProfileRouter(database *db.DB) http.Handler { + r := chi.NewRouter() + limiter := auth.NewRateLimiter() + api.MountProfileRoutes(r, database, limiter, 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(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.Exec( + "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()) + } +} + +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()) + } +} + +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("revoke") + secondSessID, _ := database.CreateSession(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("victim", mustHash(t), 4) + otherSessID, _ := database.CreateSession(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("revokeself") + sessions, _ := database.ListUserSessions(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()) + } +} diff --git a/Server/db/profile_queries.go b/Server/db/profile_queries.go new file mode 100644 index 00000000..c690b8bd --- /dev/null +++ b/Server/db/profile_queries.go @@ -0,0 +1,94 @@ +package db + +import ( + "fmt" +) + +// UpdateUserProfile updates the username and avatar for the given user. +// Returns ErrNotFound if the user does not exist. Returns an error wrapping +// a UNIQUE constraint violation if the username is already taken. +func (d *DB) UpdateUserProfile(userID int64, username string, avatar *string) error { + result, err := d.sqlDB.Exec( + `UPDATE users SET username = ?, avatar = ? WHERE id = ?`, + username, avatar, userID, + ) + if err != nil { + return fmt.Errorf("UpdateUserProfile: %w", err) + } + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("UpdateUserProfile rows: %w", err) + } + if rows == 0 { + return fmt.Errorf("UpdateUserProfile: %w", ErrNotFound) + } + return nil +} + +// UpdateUserPassword sets a new password hash for the given user. +func (d *DB) UpdateUserPassword(userID int64, newPasswordHash string) error { + _, err := d.sqlDB.Exec( + `UPDATE users SET password = ? WHERE id = ?`, + newPasswordHash, userID, + ) + if err != nil { + return fmt.Errorf("UpdateUserPassword: %w", err) + } + return nil +} + +// ListUserSessions returns all sessions for the given user in a single query. +// Results are ordered by created_at descending (newest first). +func (d *DB) ListUserSessions(userID int64) ([]Session, error) { + rows, err := d.sqlDB.Query( + `SELECT id, user_id, token, device, ip_address, created_at, last_used, expires_at + FROM sessions + WHERE user_id = ? + ORDER BY created_at DESC`, + userID, + ) + if err != nil { + return nil, fmt.Errorf("ListUserSessions: %w", err) + } + defer rows.Close() //nolint:errcheck + + var sessions []Session + for rows.Next() { + var s Session + if err := rows.Scan( + &s.ID, &s.UserID, &s.TokenHash, &s.Device, &s.IP, + &s.CreatedAt, &s.LastUsed, &s.ExpiresAt, + ); err != nil { + return nil, fmt.Errorf("ListUserSessions scan: %w", err) + } + sessions = append(sessions, s) + } + if rows.Err() != nil { + return nil, fmt.Errorf("ListUserSessions rows: %w", rows.Err()) + } + if sessions == nil { + sessions = []Session{} + } + return sessions, nil +} + +// DeleteSessionByID removes a session by its ID, but only if it belongs to +// the specified user. Returns ErrNotFound if the session does not exist or +// does not belong to the user. +func (d *DB) DeleteSessionByID(sessionID, userID int64) error { + result, err := d.sqlDB.Exec( + `DELETE FROM sessions WHERE id = ? AND user_id = ?`, + sessionID, userID, + ) + if err != nil { + return fmt.Errorf("DeleteSessionByID: %w", err) + } + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("DeleteSessionByID rows: %w", err) + } + if rows == 0 { + return fmt.Errorf("DeleteSessionByID: %w", ErrNotFound) + } + return nil +} diff --git a/Server/db/profile_queries_test.go b/Server/db/profile_queries_test.go new file mode 100644 index 00000000..6dc6aed1 --- /dev/null +++ b/Server/db/profile_queries_test.go @@ -0,0 +1,172 @@ +package db_test + +import ( + "testing" +) + +// ─── UpdateUserProfile tests ───────────────────────────────────────────────── + +func TestUpdateUserProfile_UsernameAndAvatar(t *testing.T) { + database := newTestDB(t) + id, err := database.CreateUser("profileuser", "hash", 4) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + + avatar := "https://example.com/avatar.png" + if err := database.UpdateUserProfile(id, "newname", &avatar); err != nil { + t.Fatalf("UpdateUserProfile: %v", err) + } + + user, err := database.GetUserByID(id) + if err != nil { + t.Fatalf("GetUserByID: %v", err) + } + if user.Username != "newname" { + t.Errorf("Username = %q, want %q", user.Username, "newname") + } + if user.Avatar == nil || *user.Avatar != avatar { + t.Errorf("Avatar = %v, want %q", user.Avatar, avatar) + } +} + +func TestUpdateUserProfile_UsernameOnly(t *testing.T) { + database := newTestDB(t) + id, _ := database.CreateUser("keepavatar", "hash", 4) + + if err := database.UpdateUserProfile(id, "renamed", nil); err != nil { + t.Fatalf("UpdateUserProfile: %v", err) + } + + user, _ := database.GetUserByID(id) + if user.Username != "renamed" { + t.Errorf("Username = %q, want %q", user.Username, "renamed") + } + if user.Avatar != nil { + t.Errorf("Avatar = %v, want nil", user.Avatar) + } +} + +func TestUpdateUserProfile_DuplicateUsername(t *testing.T) { + database := newTestDB(t) + database.CreateUser("existing", "hash", 4) + id2, _ := database.CreateUser("changeme", "hash", 4) + + err := database.UpdateUserProfile(id2, "existing", nil) + if err == nil { + t.Error("UpdateUserProfile with duplicate username should return error") + } +} + +func TestUpdateUserProfile_NonExistentUser(t *testing.T) { + database := newTestDB(t) + err := database.UpdateUserProfile(99999, "ghost", nil) + if err == nil { + t.Error("UpdateUserProfile for non-existent user should return error") + } +} + +// ─── UpdateUserPassword tests ──────────────────────────────────────────────── + +func TestUpdateUserPassword_Success(t *testing.T) { + database := newTestDB(t) + id, _ := database.CreateUser("pwuser", "oldhash", 4) + + if err := database.UpdateUserPassword(id, "newhash"); err != nil { + t.Fatalf("UpdateUserPassword: %v", err) + } + + user, _ := database.GetUserByID(id) + if user.PasswordHash != "newhash" { + t.Errorf("PasswordHash = %q, want %q", user.PasswordHash, "newhash") + } +} + +// ─── ListUserSessions tests ───────────────────────────────────────────────── + +func TestListUserSessions_ReturnsSessions(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("sessuser", "hash", 4) + + database.CreateSession(uid, "tok1", "Chrome", "1.2.3.4") + database.CreateSession(uid, "tok2", "Firefox", "5.6.7.8") + + sessions, err := database.ListUserSessions(uid) + if err != nil { + t.Fatalf("ListUserSessions: %v", err) + } + if len(sessions) != 2 { + t.Errorf("len(sessions) = %d, want 2", len(sessions)) + } +} + +func TestListUserSessions_EmptyArray(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("nosess", "hash", 4) + + sessions, err := database.ListUserSessions(uid) + if err != nil { + t.Fatalf("ListUserSessions: %v", err) + } + if sessions == nil { + t.Error("ListUserSessions should return empty slice, not nil") + } + if len(sessions) != 0 { + t.Errorf("len(sessions) = %d, want 0", len(sessions)) + } +} + +func TestListUserSessions_DoesNotReturnOtherUsers(t *testing.T) { + database := newTestDB(t) + uid1, _ := database.CreateUser("user1", "hash", 4) + uid2, _ := database.CreateUser("user2", "hash", 4) + + database.CreateSession(uid1, "tok-u1", "Chrome", "1.2.3.4") + database.CreateSession(uid2, "tok-u2", "Firefox", "5.6.7.8") + + sessions, _ := database.ListUserSessions(uid1) + if len(sessions) != 1 { + t.Errorf("len(sessions) = %d, want 1", len(sessions)) + } +} + +// ─── DeleteSessionByID tests ───────────────────────────────────────────────── + +func TestDeleteSessionByID_Success(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("delsess", "hash", 4) + sessID, _ := database.CreateSession(uid, "deltok", "Chrome", "1.2.3.4") + + err := database.DeleteSessionByID(sessID, uid) + if err != nil { + t.Fatalf("DeleteSessionByID: %v", err) + } + + // Session should be gone. + sess, _ := database.GetSessionByTokenHash("deltok") + if sess != nil { + t.Error("session should have been deleted") + } +} + +func TestDeleteSessionByID_WrongOwner(t *testing.T) { + database := newTestDB(t) + uid1, _ := database.CreateUser("owner1", "hash", 4) + uid2, _ := database.CreateUser("owner2", "hash", 4) + sessID, _ := database.CreateSession(uid1, "ownertok", "Chrome", "1.2.3.4") + + err := database.DeleteSessionByID(sessID, uid2) + if err == nil { + t.Error("DeleteSessionByID should fail when user does not own the session") + } +} + +func TestDeleteSessionByID_NotFound(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("delnf", "hash", 4) + + err := database.DeleteSessionByID(99999, uid) + if err == nil { + t.Error("DeleteSessionByID should fail for non-existent session") + } +}