Added user profile modification

This commit is contained in:
2025-11-11 09:30:04 +01:00
parent 43603fe66f
commit 5f652b436e
9 changed files with 986 additions and 46 deletions

View File

@@ -513,3 +513,361 @@ def test_i_cannot_access_protected_route_with_invalid_token(client, mock_auth_se
)
assert response.status_code == 401
def test_i_can_update_my_email(client, mock_auth_service, sample_user):
"""
Test user can update their own email.
Verifies that a PATCH request to /auth/me with a new email
successfully updates the user's email address.
"""
updated_user = sample_user.model_copy(update={"email": "newemail@example.com"})
mock_auth_service.get_current_user.return_value = sample_user
mock_auth_service.update_user.return_value = updated_user
response = client.patch(
"/auth/me",
headers={"Authorization": "Bearer sample.access.token"},
json={"email": "newemail@example.com"}
)
assert response.status_code == 200
data = response.json()
assert data["email"] == "newemail@example.com"
mock_auth_service.update_user.assert_called_once()
def test_i_can_update_my_username(client, mock_auth_service, sample_user):
"""
Test user can update their own username.
Verifies that a PATCH request to /auth/me with a new username
successfully updates the user's username.
"""
updated_user = sample_user.model_copy(update={"username": "newusername"})
mock_auth_service.get_current_user.return_value = sample_user
mock_auth_service.update_user.return_value = updated_user
response = client.patch(
"/auth/me",
headers={"Authorization": "Bearer sample.access.token"},
json={"username": "newusername"}
)
assert response.status_code == 200
data = response.json()
assert data["username"] == "newusername"
mock_auth_service.update_user.assert_called_once()
def test_i_can_update_my_password(client, mock_auth_service, sample_user):
"""
Test user can update their own password.
Verifies that a PATCH request to /auth/me with a new password
successfully updates the password (which will be hashed by the service).
"""
mock_auth_service.get_current_user.return_value = sample_user
mock_auth_service.update_user.return_value = sample_user
response = client.patch(
"/auth/me",
headers={"Authorization": "Bearer sample.access.token"},
json={"password": "NewSecurePass123!"}
)
assert response.status_code == 200
mock_auth_service.update_user.assert_called_once()
# Verify password was included in the update
call_args = mock_auth_service.update_user.call_args
assert call_args[1]["updates"].password == "NewSecurePass123!"
def test_i_can_update_my_password_and_preserve_session(client, mock_auth_service, sample_user):
"""
Test user can update password while preserving current session.
Verifies that when a refresh_token is provided in the request body,
it is passed to the service to preserve the current session.
"""
mock_auth_service.get_current_user.return_value = sample_user
mock_auth_service.update_user.return_value = sample_user
response = client.patch(
"/auth/me",
headers={"Authorization": "Bearer sample.access.token"},
json={
"password": "NewSecurePass123!",
"refresh_token": "current_refresh_token"
}
)
assert response.status_code == 200
mock_auth_service.update_user.assert_called_once()
# Verify refresh_token was passed to preserve session
call_args = mock_auth_service.update_user.call_args
assert call_args[1]["refresh_token"] == "current_refresh_token"
def test_i_can_update_my_user_settings(client, mock_auth_service, sample_user):
"""
Test user can update their own settings.
Verifies that a PATCH request to /auth/me with new user_settings
successfully updates the user's custom settings.
"""
new_settings = {"theme": "dark", "language": "fr", "notifications": True}
updated_user = sample_user.model_copy(update={"user_settings": new_settings})
mock_auth_service.get_current_user.return_value = sample_user
mock_auth_service.update_user.return_value = updated_user
response = client.patch(
"/auth/me",
headers={"Authorization": "Bearer sample.access.token"},
json={"user_settings": new_settings}
)
assert response.status_code == 200
data = response.json()
assert data["user_settings"] == new_settings
mock_auth_service.update_user.assert_called_once()
def test_i_can_update_multiple_fields_on_my_profile(client, mock_auth_service, sample_user):
"""
Test user can update multiple fields simultaneously.
Verifies that a PATCH request to /auth/me can update multiple
fields (email, username, user_settings) at once.
"""
updated_user = sample_user.model_copy(update={
"email": "multiemail@example.com",
"username": "multiuser",
"user_settings": {"theme": "light"}
})
mock_auth_service.get_current_user.return_value = sample_user
mock_auth_service.update_user.return_value = updated_user
response = client.patch(
"/auth/me",
headers={"Authorization": "Bearer sample.access.token"},
json={
"email": "multiemail@example.com",
"username": "multiuser",
"user_settings": {"theme": "light"}
}
)
assert response.status_code == 200
data = response.json()
assert data["email"] == "multiemail@example.com"
assert data["username"] == "multiuser"
assert data["user_settings"] == {"theme": "light"}
mock_auth_service.update_user.assert_called_once()
def test_i_cannot_update_my_profile_without_authentication(client):
"""
Test updating profile fails without authentication.
Verifies that a PATCH request to /auth/me without a Bearer token
returns 401 Unauthorized status.
"""
response = client.patch(
"/auth/me",
json={"username": "shouldfail"}
)
assert response.status_code == 401
def test_i_cannot_update_my_email_to_existing_email(client, mock_auth_service, sample_user):
"""
Test updating email fails if already exists.
Verifies that attempting to update email to an already registered
email returns 409 Conflict status.
"""
mock_auth_service.get_current_user.return_value = sample_user
mock_auth_service.update_user.side_effect = UserAlreadyExistsError(
"Email already in use"
)
response = client.patch(
"/auth/me",
headers={"Authorization": "Bearer sample.access.token"},
json={"email": "existing@example.com"}
)
assert response.status_code == 409
assert "already" in response.json()["detail"].lower()
def test_i_cannot_update_my_profile_with_invalid_token(client, mock_auth_service):
"""
Test updating profile fails with invalid token.
Verifies that attempting to access /auth/me with an invalid token
returns 401 Unauthorized status.
"""
mock_auth_service.get_current_user.side_effect = InvalidTokenError(
"Invalid access token"
)
response = client.patch(
"/auth/me",
headers={"Authorization": "Bearer invalid.token"},
json={"username": "shouldfail"}
)
assert response.status_code == 401
def test_admin_can_update_any_user(client, mock_auth_service, sample_user):
"""
Test admin can update any user.
Verifies that an admin can successfully update another user's
information via PATCH /auth/users/{user_id}.
"""
admin_user = sample_user.model_copy(update={"roles": ["admin"]})
target_user = sample_user.model_copy(update={"id": "target_user_id", "username": "targetuser"})
updated_user = target_user.model_copy(update={"username": "updatedusername"})
mock_auth_service.get_current_user.return_value = admin_user
mock_auth_service.update_user.return_value = updated_user
response = client.patch(
"/auth/users/target_user_id",
headers={"Authorization": "Bearer admin.access.token"},
json={"username": "updatedusername"}
)
assert response.status_code == 200
data = response.json()
assert data["username"] == "updatedusername"
mock_auth_service.update_user.assert_called_once()
def test_admin_can_update_user_roles(client, mock_auth_service, sample_user):
"""
Test admin can update user roles.
Verifies that an admin can change a user's roles, which is forbidden
for regular users on the /auth/me endpoint.
"""
admin_user = sample_user.model_copy(update={"roles": ["admin"]})
target_user = sample_user.model_copy(update={"id": "target_user_id", "roles": ["user"]})
updated_user = target_user.model_copy(update={"roles": ["admin", "moderator"]})
mock_auth_service.get_current_user.return_value = admin_user
mock_auth_service.update_user.return_value = updated_user
response = client.patch(
"/auth/users/target_user_id",
headers={"Authorization": "Bearer admin.access.token"},
json={"roles": ["admin", "moderator"]}
)
assert response.status_code == 200
data = response.json()
assert data["roles"] == ["admin", "moderator"]
mock_auth_service.update_user.assert_called_once()
def test_admin_can_update_user_is_active(client, mock_auth_service, sample_user):
"""
Test admin can update user active status.
Verifies that an admin can activate or deactivate a user account,
which is forbidden for regular users on the /auth/me endpoint.
"""
admin_user = sample_user.model_copy(update={"roles": ["admin"]})
target_user = sample_user.model_copy(update={"id": "target_user_id", "is_active": True})
updated_user = target_user.model_copy(update={"is_active": False})
mock_auth_service.get_current_user.return_value = admin_user
mock_auth_service.update_user.return_value = updated_user
response = client.patch(
"/auth/users/target_user_id",
headers={"Authorization": "Bearer admin.access.token"},
json={"is_active": False}
)
assert response.status_code == 200
# Note: is_active is not in UserResponse, so we just verify the call was made
mock_auth_service.update_user.assert_called_once()
call_args = mock_auth_service.update_user.call_args
assert call_args[1]["updates"].is_active is False
def test_admin_can_update_user_is_verified(client, mock_auth_service, sample_user):
"""
Test admin can update user verification status.
Verifies that an admin can change a user's email verification status,
which is forbidden for regular users on the /auth/me endpoint.
"""
admin_user = sample_user.model_copy(update={"roles": ["admin"]})
target_user = sample_user.model_copy(update={"id": "target_user_id", "is_verified": False})
updated_user = target_user.model_copy(update={"is_verified": True})
mock_auth_service.get_current_user.return_value = admin_user
mock_auth_service.update_user.return_value = updated_user
response = client.patch(
"/auth/users/target_user_id",
headers={"Authorization": "Bearer admin.access.token"},
json={"is_verified": True}
)
assert response.status_code == 200
# Note: is_verified is not in UserResponse, so we just verify the call was made
mock_auth_service.update_user.assert_called_once()
call_args = mock_auth_service.update_user.call_args
assert call_args[1]["updates"].is_verified is True
def test_non_admin_cannot_update_other_users(client, mock_auth_service, sample_user):
"""
Test non-admin cannot update other users.
Verifies that a regular user (without admin role) cannot access
the PATCH /auth/users/{user_id} endpoint and receives 403 Forbidden.
"""
regular_user = sample_user.model_copy(update={"roles": ["user"]})
mock_auth_service.get_current_user.return_value = regular_user
response = client.patch(
"/auth/users/other_user_id",
headers={"Authorization": "Bearer user.access.token"},
json={"username": "shouldfail"}
)
assert response.status_code == 403
assert "admin" in response.json()["detail"].lower()
def test_admin_cannot_update_non_existent_user(client, mock_auth_service, sample_user):
"""
Test admin cannot update non-existent user.
Verifies that attempting to update a non-existent user returns
404 Not Found status.
"""
admin_user = sample_user.model_copy(update={"roles": ["admin"]})
mock_auth_service.get_current_user.return_value = admin_user
mock_auth_service.update_user.side_effect = UserNotFoundError(
"User not found"
)
response = client.patch(
"/auth/users/non_existent_id",
headers={"Authorization": "Bearer admin.access.token"},
json={"username": "shouldfail"}
)
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()

View File

@@ -343,6 +343,297 @@ class TestAuthServiceTokenManagement(object):
with pytest.raises(ExpiredTokenError):
auth_service.get_current_user("expired_access_jwt")
class TestAuthServiceUserUpdate(object):
"""Tests for user update operations."""
@pytest.fixture(autouse=True)
def setup_user(self, auth_service: AuthService, test_user_data_create: UserCreate):
"""Sets up a registered user for update tests."""
pm = auth_service.password_manager
original_hash = pm.hash_password.return_value
# Temporarily set hash for setup
pm.hash_password.return_value = "HASHED_PASS"
user = auth_service.register(test_user_data_create)
self.user = user
self.original_email = user.email
self.original_username = user.username
# Restore hash mock
pm.hash_password.return_value = original_hash
def test_i_can_update_user_email(self, auth_service: AuthService):
"""Success: Email can be updated and is_verified is automatically set to False."""
from myauth.models.user import UserUpdate
new_email = "updated.email@example.com"
updates = UserUpdate(email=new_email)
updated_user = auth_service.update_user(self.user.id, updates)
assert updated_user.email == new_email
assert updated_user.is_verified is False
def test_i_can_update_user_username(self, auth_service: AuthService):
"""Success: Username can be updated."""
from myauth.models.user import UserUpdate
new_username = "UpdatedUsername"
updates = UserUpdate(username=new_username)
updated_user = auth_service.update_user(self.user.id, updates)
assert updated_user.username == new_username
def test_i_can_update_user_password(self, auth_service: AuthService):
"""Success: Password can be updated and is properly hashed."""
from myauth.models.user import UserUpdate
pm = auth_service.password_manager
new_password = "NewSecurePass123!"
with patch.object(pm, 'hash_password', return_value="NEW_HASHED_PASSWORD") as mock_hash:
updates = UserUpdate(password=new_password)
updated_user = auth_service.update_user(self.user.id, updates)
mock_hash.assert_called_once_with(new_password)
assert updated_user.hashed_password == "NEW_HASHED_PASSWORD"
def test_i_can_update_user_password_and_all_refresh_tokens_are_revoked(
self,
auth_service: AuthService
):
"""Success: Updating password revokes all refresh tokens when no current token provided."""
from myauth.models.user import UserUpdate
# Setup: Create some refresh tokens for the user
from myauth.models.token import TokenData
from datetime import datetime, timedelta
token1 = TokenData(
token="refresh_token_1",
token_type="refresh",
user_id=self.user.id,
expires_at=datetime.now() + timedelta(days=1),
created_at=datetime.now(),
is_revoked=False
)
token2 = TokenData(
token="refresh_token_2",
token_type="refresh",
user_id=self.user.id,
expires_at=datetime.now() + timedelta(days=1),
created_at=datetime.now(),
is_revoked=False
)
auth_service.token_repository.save_token(token1)
auth_service.token_repository.save_token(token2)
# Execute: Update password without providing current token
updates = UserUpdate(password="NewPassword123!")
auth_service.update_user(self.user.id, updates)
# Verify: Both tokens are revoked
token1_after = auth_service.token_repository.get_token("refresh_token_1", "refresh")
token2_after = auth_service.token_repository.get_token("refresh_token_2", "refresh")
assert token1_after.is_revoked is True
assert token2_after.is_revoked is True
def test_i_can_update_user_password_and_preserve_current_session(
self,
auth_service: AuthService
):
"""Success: Updating password preserves the current refresh token when provided."""
from myauth.models.user import UserUpdate
from myauth.models.token import TokenData
from datetime import datetime, timedelta
# Setup: Create some refresh tokens
current_token = TokenData(
token="current_refresh_token",
token_type="refresh",
user_id=self.user.id,
expires_at=datetime.now() + timedelta(days=1),
created_at=datetime.now(),
is_revoked=False
)
other_token = TokenData(
token="other_refresh_token",
token_type="refresh",
user_id=self.user.id,
expires_at=datetime.now() + timedelta(days=1),
created_at=datetime.now(),
is_revoked=False
)
auth_service.token_repository.save_token(current_token)
auth_service.token_repository.save_token(other_token)
# Execute: Update password while providing current token
updates = UserUpdate(password="NewPassword123!")
auth_service.update_user(self.user.id, updates, refresh_token="current_refresh_token")
# Verify: Current token is preserved, other is revoked
current_after = auth_service.token_repository.get_token("current_refresh_token", "refresh")
other_after = auth_service.token_repository.get_token("other_refresh_token", "refresh")
assert current_after.is_revoked is False
assert other_after.is_revoked is True
def test_i_can_update_multiple_fields_at_once(self, auth_service: AuthService):
"""Success: Multiple fields can be updated simultaneously."""
from myauth.models.user import UserUpdate
updates = UserUpdate(
username="MultiUpdateUser",
roles=["admin", "member"],
user_settings={"theme": "light", "language": "en"}
)
updated_user = auth_service.update_user(self.user.id, updates)
assert updated_user.username == "MultiUpdateUser"
assert updated_user.roles == ["admin", "member"]
assert updated_user.user_settings == {"theme": "light", "language": "en"}
def test_i_can_update_user_roles(self, auth_service: AuthService):
"""Success: User roles can be updated."""
from myauth.models.user import UserUpdate
new_roles = ["admin", "moderator"]
updates = UserUpdate(roles=new_roles)
updated_user = auth_service.update_user(self.user.id, updates)
assert updated_user.roles == new_roles
def test_i_can_update_user_settings(self, auth_service: AuthService):
"""Success: User settings can be updated."""
from myauth.models.user import UserUpdate
new_settings = {"theme": "light", "notifications": True, "language": "fr"}
updates = UserUpdate(user_settings=new_settings)
updated_user = auth_service.update_user(self.user.id, updates)
assert updated_user.user_settings == new_settings
def test_i_can_update_is_active_status(self, auth_service: AuthService):
"""Success: User active status can be updated."""
from myauth.models.user import UserUpdate
# Deactivate user
updates = UserUpdate(is_active=False)
updated_user = auth_service.update_user(self.user.id, updates)
assert updated_user.is_active is False
# Reactivate user
updates = UserUpdate(is_active=True)
updated_user = auth_service.update_user(self.user.id, updates)
assert updated_user.is_active is True
def test_i_cannot_update_user_with_invalid_user_id(self, auth_service: AuthService):
"""Failure: Updating a non-existent user raises UserNotFoundError."""
from myauth.models.user import UserUpdate
from myauth.exceptions import UserNotFoundError
updates = UserUpdate(username="ShouldFail")
with pytest.raises(UserNotFoundError):
auth_service.update_user("non_existent_id", updates)
def test_i_cannot_update_email_to_existing_email(self, auth_service: AuthService):
"""Failure: Updating email to an already registered email raises UserAlreadyExistsError."""
from myauth.models.user import UserCreate, UserUpdate
# Setup: Create another user with a different email
other_user_data = UserCreate(
email="other.user@example.com",
username="OtherUser",
password="OtherPass123!",
roles=["member"]
)
auth_service.register(other_user_data)
# Execute: Try to update original user's email to the other user's email
updates = UserUpdate(email="other.user@example.com")
with pytest.raises(UserAlreadyExistsError):
auth_service.update_user(self.user.id, updates)
def test_i_cannot_update_email_to_same_email_without_triggering_verification_reset(
self,
auth_service: AuthService
):
"""Success: Updating email to the same email does not reset is_verified."""
from myauth.models.user import UserUpdate
# Setup: Ensure user is verified
verify_updates = UserUpdate(is_verified=True)
auth_service.update_user(self.user.id, verify_updates)
# Execute: Update with the same email
updates = UserUpdate(email=self.original_email)
updated_user = auth_service.update_user(self.user.id, updates)
# Verify: is_verified should remain True
assert updated_user.is_verified is True
assert updated_user.email == self.original_email
def test_i_can_update_with_empty_updates(self, auth_service: AuthService):
"""Success: Updating with empty UserUpdate does not cause errors."""
from myauth.models.user import UserUpdate
updates = UserUpdate()
updated_user = auth_service.update_user(self.user.id, updates)
# Verify: User is returned and fields are unchanged
assert updated_user.id == self.user.id
assert updated_user.email == self.original_email
assert updated_user.username == self.original_username
def test_email_verification_reset_only_when_email_actually_changes(
self,
auth_service: AuthService
):
"""Success: is_verified is reset to False only when email actually changes."""
from myauth.models.user import UserUpdate
# Setup: Set user as verified
verify_updates = UserUpdate(is_verified=True)
auth_service.update_user(self.user.id, verify_updates)
verified_user = auth_service.user_repository.get_user_by_id(self.user.id)
assert verified_user.is_verified is True
# Test 1: Update with same email - verification should remain
same_email_updates = UserUpdate(email=self.original_email, username="SameEmailTest")
updated_user = auth_service.update_user(self.user.id, same_email_updates)
assert updated_user.is_verified is True
# Test 2: Update with different email - verification should reset
different_email_updates = UserUpdate(email="completely.new@example.com")
updated_user = auth_service.update_user(self.user.id, different_email_updates)
assert updated_user.is_verified is False
assert updated_user.email == "completely.new@example.com"
# class TestAuthServiceResetVerification(object):
# """Tests for password reset and email verification flows."""
#