mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user