feat: add user profile management endpoints (T-195)

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.
This commit is contained in:
jevb
2026-04-01 11:37:55 +02:00
parent ecbffddf19
commit b4e15e1234
4 changed files with 911 additions and 0 deletions
+313
View File
@@ -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)
}
}
+332
View File
@@ -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())
}
}
+94
View File
@@ -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
}
+172
View File
@@ -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")
}
}