NeonCharlie-24 Daniel McKnight commited on
Commit
2fabb8c
·
unverified ·
1 Parent(s): 39686da

Feat/account management (#41)

Browse files

* added unit tests for account management endpoints.

* added account management endpoints (password change, profile update, account deletion).

* added param/return docstrings to all auth endpoints.

* added Pydantic models for logout, change_password, and delete_account endpoints.

* improved error handling for account management endpoints.

* simplified unit test imports.

* update min password verification length to 8 chars to match front-end Signup.js expectation.

* updated delete endpoint to include deleting PhD Canvases.

* updated delete_account docstring to note PhD Canvases also deleted.

* fix incorrect naming case

Co-authored-by: Daniel McKnight <34697904+NeonDaniel@users.noreply.github.com>

* fix UpdateProfileRequest name references.

* added model validators to ChangePasswordRequest and UpdateProfileRequest.

---------

Co-authored-by: Daniel McKnight <34697904+NeonDaniel@users.noreply.github.com>

multi_llm_chatbot_backend/app/api/routes/auth.py CHANGED
@@ -1,9 +1,11 @@
1
  from fastapi import APIRouter, HTTPException, Depends, status
2
  from datetime import datetime, timedelta
3
- from bson import ObjectId
4
  from app.models.user import UserCreate, UserLogin, User, Token, UserResponse
 
 
5
  from app.core.auth import (
6
  get_password_hash,
 
7
  authenticate_user,
8
  create_access_token,
9
  get_user_by_email,
@@ -16,11 +18,50 @@ import logging
16
 
17
  logger = logging.getLogger(__name__)
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  router = APIRouter()
20
 
21
  @router.post("/signup", response_model=Token)
22
  async def signup(user_data: UserCreate):
23
- """Create a new user account"""
 
 
 
 
24
  try:
25
  db = get_database()
26
 
@@ -73,7 +114,11 @@ async def signup(user_data: UserCreate):
73
 
74
  @router.post("/login", response_model=Token)
75
  async def login(user_credentials: UserLogin):
76
- """Login with email and password"""
 
 
 
 
77
  try:
78
  # Authenticate user
79
  user = await authenticate_user(user_credentials.email, user_credentials.password)
@@ -116,15 +161,128 @@ async def login(user_credentials: UserLogin):
116
 
117
  @router.get("/me", response_model=UserResponse)
118
  async def get_current_user_profile(current_user: User = Depends(get_current_active_user)):
119
- """Get current user profile"""
 
 
 
 
120
  return create_user_response(current_user)
121
 
122
- @router.post("/logout")
123
  async def logout():
124
- """Logout (client should discard token)"""
125
- return {"message": "Successfully logged out"}
 
 
 
126
 
127
  @router.post("/verify-token", response_model=UserResponse)
128
  async def verify_token(current_user: User = Depends(get_current_active_user)):
129
- """Verify token and return user info"""
130
- return create_user_response(current_user)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from fastapi import APIRouter, HTTPException, Depends, status
2
  from datetime import datetime, timedelta
 
3
  from app.models.user import UserCreate, UserLogin, User, Token, UserResponse
4
+ from pydantic import BaseModel, model_validator
5
+ from typing import Optional
6
  from app.core.auth import (
7
  get_password_hash,
8
+ verify_password,
9
  authenticate_user,
10
  create_access_token,
11
  get_user_by_email,
 
18
 
19
  logger = logging.getLogger(__name__)
20
 
21
+
22
+ class MessageResponse(BaseModel):
23
+ message: str
24
+
25
+
26
+ class ChangePasswordRequest(BaseModel):
27
+ current_password: str
28
+ new_password: str
29
+
30
+ @model_validator(mode="after")
31
+ def passwords_must_differ(self):
32
+ if self.current_password == self.new_password:
33
+ raise ValueError("New password must be different from the current password")
34
+ return self
35
+
36
+
37
+ class UpdateProfileRequest(BaseModel):
38
+ first_name: Optional[str] = None
39
+ last_name: Optional[str] = None
40
+
41
+ @model_validator(mode="after")
42
+ def at_least_one_field(self):
43
+ if self.first_name is not None:
44
+ self.first_name = self.first_name.strip() or None
45
+ if self.last_name is not None:
46
+ self.last_name = self.last_name.strip() or None
47
+ if self.first_name is None and self.last_name is None:
48
+ raise ValueError("At least one field must be provided")
49
+ return self
50
+
51
+
52
+ class DeleteAccountRequest(BaseModel):
53
+ password: str
54
+
55
+
56
  router = APIRouter()
57
 
58
  @router.post("/signup", response_model=Token)
59
  async def signup(user_data: UserCreate):
60
+ """
61
+ Register a new user and return an access token.
62
+ @param user_data: UserCreate with name, email, password, and optional academic fields
63
+ @return: Token containing a JWT access token and the created UserResponse
64
+ """
65
  try:
66
  db = get_database()
67
 
 
114
 
115
  @router.post("/login", response_model=Token)
116
  async def login(user_credentials: UserLogin):
117
+ """
118
+ Authenticate a user and return an access token.
119
+ @param user_credentials: UserLogin with email and password
120
+ @return: Token containing a JWT access token and the authenticated UserResponse
121
+ """
122
  try:
123
  # Authenticate user
124
  user = await authenticate_user(user_credentials.email, user_credentials.password)
 
161
 
162
  @router.get("/me", response_model=UserResponse)
163
  async def get_current_user_profile(current_user: User = Depends(get_current_active_user)):
164
+ """
165
+ Retrieve the profile of the currently authenticated user.
166
+ @param current_user: Authenticated user from dependency injection
167
+ @return: UserResponse with the user's profile information
168
+ """
169
  return create_user_response(current_user)
170
 
171
+ @router.post("/logout", response_model=MessageResponse)
172
  async def logout():
173
+ """
174
+ Log out the current user (client should discard the token).
175
+ @return: MessageResponse with a confirmation message
176
+ """
177
+ return MessageResponse(message="Successfully logged out")
178
 
179
  @router.post("/verify-token", response_model=UserResponse)
180
  async def verify_token(current_user: User = Depends(get_current_active_user)):
181
+ """
182
+ Validate the caller's JWT and return their profile.
183
+ @param current_user: Authenticated user from dependency injection
184
+ @return: UserResponse with the user's profile information
185
+ """
186
+ return create_user_response(current_user)
187
+
188
+ @router.post("/me/password", response_model=MessageResponse)
189
+ async def change_password(
190
+ body: ChangePasswordRequest,
191
+ current_user: User = Depends(get_current_active_user),
192
+ ):
193
+ """
194
+ Change the authenticated user's password.
195
+ @param body: ChangePasswordRequest with the current and new passwords
196
+ @param current_user: Authenticated user from dependency injection
197
+ @return: MessageResponse with a confirmation message
198
+ """
199
+ try:
200
+ if not verify_password(body.current_password, current_user.hashed_password):
201
+ raise HTTPException(
202
+ status_code=status.HTTP_400_BAD_REQUEST,
203
+ detail="Current password is incorrect",
204
+ )
205
+ if len(body.new_password) < 8:
206
+ raise HTTPException(
207
+ status_code=status.HTTP_400_BAD_REQUEST,
208
+ detail="New password must be at least 8 characters",
209
+ )
210
+ db = get_database()
211
+ await db.users.update_one(
212
+ {"_id": current_user.id},
213
+ {"$set": {"hashed_password": get_password_hash(body.new_password)}},
214
+ )
215
+ return MessageResponse(message="Password changed successfully")
216
+
217
+ except HTTPException:
218
+ raise
219
+ except Exception as e:
220
+ logger.error(f"Error during password change: {e}")
221
+ raise HTTPException(
222
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
223
+ detail="Could not change password"
224
+ )
225
+
226
+ @router.patch("/me", response_model=UserResponse)
227
+ async def update_profile(
228
+ body: UpdateProfileRequest,
229
+ current_user: User = Depends(get_current_active_user),
230
+ ):
231
+ """
232
+ Update the authenticated user's profile fields.
233
+ @param body: UpdateProfileRequest with optional firstName and lastName
234
+ @param current_user: Authenticated user from dependency injection
235
+ @return: UserResponse with the updated profile information
236
+ """
237
+ try:
238
+ updates = {}
239
+ if body.first_name is not None:
240
+ updates["firstName"] = body.first_name
241
+ if body.last_name is not None:
242
+ updates["lastName"] = body.last_name
243
+ db = get_database()
244
+ await db.users.update_one({"_id": current_user.id}, {"$set": updates})
245
+ updated_user = await db.users.find_one({"_id": current_user.id})
246
+ return create_user_response(User(**updated_user))
247
+
248
+ except HTTPException:
249
+ raise
250
+ except Exception as e:
251
+ logger.error(f"Error during profile update: {e}")
252
+ raise HTTPException(
253
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
254
+ detail="Could not update profile"
255
+ )
256
+
257
+ @router.delete("/me", response_model=MessageResponse)
258
+ async def delete_account(
259
+ body: DeleteAccountRequest,
260
+ current_user: User = Depends(get_current_active_user),
261
+ ):
262
+ """
263
+ Permanently delete the authenticated user's account, all chat sessions, and all PhD Canvas data.
264
+ @param body: DeleteAccountRequest with the user's password for confirmation
265
+ @param current_user: Authenticated user from dependency injection
266
+ @return: MessageResponse with a confirmation message
267
+ """
268
+ try:
269
+ if not verify_password(body.password, current_user.hashed_password):
270
+ raise HTTPException(
271
+ status_code=status.HTTP_400_BAD_REQUEST,
272
+ detail="Incorrect password",
273
+ )
274
+ db = get_database()
275
+ uid = current_user.id
276
+ await db.chat_sessions.delete_many({"user_id": uid})
277
+ await db.phd_canvases.delete_many({"user_id": uid})
278
+ await db.users.delete_one({"_id": uid})
279
+ return MessageResponse(message="Account deleted")
280
+
281
+ except HTTPException:
282
+ raise
283
+ except Exception as e:
284
+ logger.error(f"Error during account deletion: {e}")
285
+ raise HTTPException(
286
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
287
+ detail="Could not delete account"
288
+ )
multi_llm_chatbot_backend/app/tests/unit/test_account_management.py ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import sys
3
+ import unittest
4
+ from datetime import datetime
5
+ from unittest.mock import AsyncMock, MagicMock, patch
6
+
7
+ from bson import ObjectId
8
+ from fastapi import APIRouter, HTTPException
9
+ from pydantic import ValidationError
10
+
11
+ # app.api.routes.__init__ eagerly imports and wires routers from every
12
+ # sibling route module, several of which spin up the LLM stack, NLTK
13
+ # downloads, and ChromaDB at import time. Stub those heavy modules with
14
+ # harmless substitutes so the package imports cleanly and auth.py can
15
+ # be loaded via normal import machinery.
16
+ for _name in ("app.core.bootstrap", "app.core.rag_manager"):
17
+ sys.modules.setdefault(_name, MagicMock())
18
+
19
+ _stub_router_module = MagicMock(router=APIRouter())
20
+ for _name in (
21
+ "app.api.routes.chat",
22
+ "app.api.routes.documents",
23
+ "app.api.routes.sessions",
24
+ "app.api.routes.provider",
25
+ "app.api.routes.debug",
26
+ "app.api.routes.root",
27
+ "app.api.routes.phd_canvas",
28
+ ):
29
+ sys.modules.setdefault(_name, _stub_router_module)
30
+
31
+ from app.api.routes.auth import ( # noqa: E402
32
+ ChangePasswordRequest,
33
+ DeleteAccountRequest,
34
+ UpdateProfileRequest,
35
+ change_password,
36
+ delete_account,
37
+ update_profile,
38
+ )
39
+ from app.models.user import User # noqa: E402
40
+
41
+ FAKE_USER_ID = ObjectId()
42
+
43
+
44
+ def _make_fake_user(**overrides):
45
+ defaults = dict(
46
+ _id=FAKE_USER_ID,
47
+ firstName="Test",
48
+ lastName="User",
49
+ email="test@example.com",
50
+ hashed_password="$2b$12$fakehash",
51
+ is_active=True,
52
+ created_at=datetime(2025, 1, 1),
53
+ )
54
+ defaults.update(overrides)
55
+ return User(**defaults)
56
+
57
+
58
+ def _mock_db():
59
+ db = MagicMock()
60
+ db.users.update_one = AsyncMock()
61
+ db.users.delete_one = AsyncMock()
62
+ db.users.find_one = AsyncMock()
63
+ db.chat_sessions.delete_many = AsyncMock()
64
+ db.phd_canvases.delete_many = AsyncMock()
65
+ return db
66
+
67
+
68
+ # ------------------------------------------------------------------
69
+ # POST /auth/me/password
70
+ # ------------------------------------------------------------------
71
+
72
+
73
+ @patch("app.api.routes.auth.get_database")
74
+ @patch("app.api.routes.auth.get_password_hash", return_value="new_hashed")
75
+ @patch("app.api.routes.auth.verify_password")
76
+ class TestChangePassword(unittest.TestCase):
77
+
78
+ def test_success(self, mock_verify, mock_hash, mock_get_db):
79
+ mock_verify.return_value = True
80
+ db = _mock_db()
81
+ mock_get_db.return_value = db
82
+
83
+ user = _make_fake_user()
84
+ body = ChangePasswordRequest(
85
+ current_password="old", new_password="newsecure",
86
+ )
87
+
88
+ result = asyncio.run(change_password(body=body, current_user=user))
89
+
90
+ mock_verify.assert_called_once_with("old", user.hashed_password)
91
+ mock_hash.assert_called_once_with("newsecure")
92
+ db.users.update_one.assert_called_once_with(
93
+ {"_id": user.id},
94
+ {"$set": {"hashed_password": "new_hashed"}},
95
+ )
96
+ self.assertEqual(result.message, "Password changed successfully")
97
+
98
+ def test_wrong_current_password(self, mock_verify, mock_hash, mock_get_db):
99
+ mock_verify.return_value = False
100
+
101
+ user = _make_fake_user()
102
+ body = ChangePasswordRequest(
103
+ current_password="wrong", new_password="newsecure",
104
+ )
105
+
106
+ with self.assertRaises(HTTPException) as ctx:
107
+ asyncio.run(change_password(body=body, current_user=user))
108
+
109
+ self.assertEqual(ctx.exception.status_code, 400)
110
+ self.assertIn("incorrect", ctx.exception.detail.lower())
111
+
112
+ def test_new_password_too_short(self, mock_verify, mock_hash, mock_get_db):
113
+ mock_verify.return_value = True
114
+
115
+ user = _make_fake_user()
116
+ body = ChangePasswordRequest(
117
+ current_password="old", new_password="short",
118
+ )
119
+
120
+ with self.assertRaises(HTTPException) as ctx:
121
+ asyncio.run(change_password(body=body, current_user=user))
122
+
123
+ self.assertEqual(ctx.exception.status_code, 400)
124
+ self.assertIn("8 characters", ctx.exception.detail)
125
+
126
+ def test_db_not_called_on_wrong_password(self, mock_verify, mock_hash, mock_get_db):
127
+ mock_verify.return_value = False
128
+ db = _mock_db()
129
+ mock_get_db.return_value = db
130
+
131
+ user = _make_fake_user()
132
+ body = ChangePasswordRequest(
133
+ current_password="wrong", new_password="newsecure",
134
+ )
135
+
136
+ with self.assertRaises(HTTPException):
137
+ asyncio.run(change_password(body=body, current_user=user))
138
+
139
+ db.users.update_one.assert_not_called()
140
+
141
+ def test_same_password_rejected(self, mock_verify, mock_hash, mock_get_db):
142
+ with self.assertRaises(ValidationError) as ctx:
143
+ ChangePasswordRequest(
144
+ current_password="samepass", new_password="samepass",
145
+ )
146
+
147
+ self.assertIn("different", str(ctx.exception).lower())
148
+
149
+
150
+ # ------------------------------------------------------------------
151
+ # PATCH /auth/me
152
+ # ------------------------------------------------------------------
153
+
154
+
155
+ @patch("app.api.routes.auth.get_database")
156
+ class TestUpdateProfile(unittest.TestCase):
157
+
158
+ def test_update_first_name(self, mock_get_db):
159
+ user = _make_fake_user()
160
+ updated_doc = {**user.model_dump(by_alias=True), "firstName": "Alice"}
161
+ db = _mock_db()
162
+ db.users.find_one = AsyncMock(return_value=updated_doc)
163
+ mock_get_db.return_value = db
164
+
165
+ body = UpdateProfileRequest(first_name="Alice")
166
+ result = asyncio.run(update_profile(body=body, current_user=user))
167
+
168
+ db.users.update_one.assert_called_once_with(
169
+ {"_id": user.id},
170
+ {"$set": {"firstName": "Alice"}},
171
+ )
172
+ self.assertEqual(result.firstName, "Alice")
173
+
174
+ def test_update_both_names(self, mock_get_db):
175
+ user = _make_fake_user()
176
+ updated_doc = {
177
+ **user.model_dump(by_alias=True),
178
+ "firstName": "Alice",
179
+ "lastName": "Smith",
180
+ }
181
+ db = _mock_db()
182
+ db.users.find_one = AsyncMock(return_value=updated_doc)
183
+ mock_get_db.return_value = db
184
+
185
+ body = UpdateProfileRequest(first_name="Alice", last_name="Smith")
186
+ result = asyncio.run(update_profile(body=body, current_user=user))
187
+
188
+ db.users.update_one.assert_called_once_with(
189
+ {"_id": user.id},
190
+ {"$set": {"firstName": "Alice", "lastName": "Smith"}},
191
+ )
192
+ self.assertEqual(result.firstName, "Alice")
193
+ self.assertEqual(result.lastName, "Smith")
194
+
195
+ def test_empty_body_rejected(self, mock_get_db):
196
+ with self.assertRaises(ValidationError) as ctx:
197
+ UpdateProfileRequest()
198
+
199
+ self.assertIn("at least one field", str(ctx.exception).lower())
200
+
201
+ def test_strips_whitespace(self, mock_get_db):
202
+ user = _make_fake_user()
203
+ updated_doc = {**user.model_dump(by_alias=True), "firstName": "Alice"}
204
+ db = _mock_db()
205
+ db.users.find_one = AsyncMock(return_value=updated_doc)
206
+ mock_get_db.return_value = db
207
+
208
+ body = UpdateProfileRequest(first_name=" Alice ")
209
+ self.assertEqual(body.first_name, "Alice")
210
+
211
+ asyncio.run(update_profile(body=body, current_user=user))
212
+
213
+ db.users.update_one.assert_called_once_with(
214
+ {"_id": user.id},
215
+ {"$set": {"firstName": "Alice"}},
216
+ )
217
+
218
+ def test_whitespace_only_body_rejected(self, mock_get_db):
219
+ with self.assertRaises(ValidationError) as ctx:
220
+ UpdateProfileRequest(first_name=" ")
221
+
222
+ self.assertIn("at least one field", str(ctx.exception).lower())
223
+
224
+
225
+ # ------------------------------------------------------------------
226
+ # DELETE /auth/me
227
+ # ------------------------------------------------------------------
228
+
229
+
230
+ @patch("app.api.routes.auth.get_database")
231
+ @patch("app.api.routes.auth.verify_password")
232
+ class TestDeleteAccount(unittest.TestCase):
233
+
234
+ def test_success(self, mock_verify, mock_get_db):
235
+ mock_verify.return_value = True
236
+ db = _mock_db()
237
+ mock_get_db.return_value = db
238
+
239
+ user = _make_fake_user()
240
+ body = DeleteAccountRequest(password="correct")
241
+
242
+ result = asyncio.run(delete_account(body=body, current_user=user))
243
+
244
+ mock_verify.assert_called_once_with("correct", user.hashed_password)
245
+ db.chat_sessions.delete_many.assert_called_once_with({"user_id": user.id})
246
+ db.phd_canvases.delete_many.assert_called_once_with({"user_id": user.id})
247
+ db.users.delete_one.assert_called_once_with({"_id": user.id})
248
+ self.assertEqual(result.message, "Account deleted")
249
+
250
+ def test_wrong_password(self, mock_verify, mock_get_db):
251
+ mock_verify.return_value = False
252
+ db = _mock_db()
253
+ mock_get_db.return_value = db
254
+
255
+ user = _make_fake_user()
256
+ body = DeleteAccountRequest(password="wrong")
257
+
258
+ with self.assertRaises(HTTPException) as ctx:
259
+ asyncio.run(delete_account(body=body, current_user=user))
260
+
261
+ self.assertEqual(ctx.exception.status_code, 400)
262
+ self.assertIn("Incorrect password", ctx.exception.detail)
263
+
264
+ def test_no_deletion_on_wrong_password(self, mock_verify, mock_get_db):
265
+ mock_verify.return_value = False
266
+ db = _mock_db()
267
+ mock_get_db.return_value = db
268
+
269
+ user = _make_fake_user()
270
+ body = DeleteAccountRequest(password="wrong")
271
+
272
+ with self.assertRaises(HTTPException):
273
+ asyncio.run(delete_account(body=body, current_user=user))
274
+
275
+ db.users.delete_one.assert_not_called()
276
+ db.chat_sessions.delete_many.assert_not_called()
277
+ db.phd_canvases.delete_many.assert_not_called()