Refactor authentication services to async password handling; optimize bulk operations and queries

- Updated `verify_password` and `get_password_hash` to their async counterparts to prevent event loop blocking.
- Replaced N+1 query patterns in `admin.py` and `session_async.py` with optimized bulk operations for improved performance.
- Enhanced `user_async.py` with bulk update and soft delete methods for efficient user management.
- Added eager loading support in CRUD operations to prevent N+1 query issues.
- Updated test cases with stronger password examples for better security representation.
This commit is contained in:
Felipe Cardoso
2025-11-01 03:53:22 +01:00
parent 819f3ba963
commit 3fe5d301f8
17 changed files with 397 additions and 163 deletions

View File

@@ -30,7 +30,7 @@ class TestRegisterEndpoint:
"/api/v1/auth/register",
json={
"email": "newuser@example.com",
"password": "SecurePassword123",
"password": "SecurePassword123!",
"first_name": "New",
"last_name": "User"
}
@@ -49,7 +49,7 @@ class TestRegisterEndpoint:
"/api/v1/auth/register",
json={
"email": async_test_user.email,
"password": "SecurePassword123",
"password": "SecurePassword123!",
"first_name": "Duplicate",
"last_name": "User"
}
@@ -103,7 +103,7 @@ class TestLoginEndpoint:
"/api/v1/auth/login",
json={
"email": async_test_user.email,
"password": "TestPassword123"
"password": "TestPassword123!"
}
)
@@ -133,7 +133,7 @@ class TestLoginEndpoint:
"/api/v1/auth/login",
json={
"email": "nonexistent@example.com",
"password": "Password123"
"password": "Password123!"
}
)
@@ -154,7 +154,7 @@ class TestLoginEndpoint:
"/api/v1/auth/login",
json={
"email": async_test_user.email,
"password": "TestPassword123"
"password": "TestPassword123!"
}
)
@@ -170,7 +170,7 @@ class TestLoginEndpoint:
"/api/v1/auth/login",
json={
"email": async_test_user.email,
"password": "TestPassword123"
"password": "TestPassword123!"
}
)
@@ -187,7 +187,7 @@ class TestOAuthLoginEndpoint:
"/api/v1/auth/login/oauth",
data={
"username": async_test_user.email,
"password": "TestPassword123"
"password": "TestPassword123!"
}
)
@@ -224,7 +224,7 @@ class TestOAuthLoginEndpoint:
"/api/v1/auth/login/oauth",
data={
"username": async_test_user.email,
"password": "TestPassword123"
"password": "TestPassword123!"
}
)
@@ -240,7 +240,7 @@ class TestOAuthLoginEndpoint:
"/api/v1/auth/login/oauth",
data={
"username": async_test_user.email,
"password": "TestPassword123"
"password": "TestPassword123!"
}
)
@@ -258,7 +258,7 @@ class TestRefreshTokenEndpoint:
"/api/v1/auth/login",
json={
"email": async_test_user.email,
"password": "TestPassword123"
"password": "TestPassword123!"
}
)
refresh_token = login_response.json()["refresh_token"]
@@ -307,7 +307,7 @@ class TestRefreshTokenEndpoint:
"/api/v1/auth/login",
json={
"email": async_test_user.email,
"password": "TestPassword123"
"password": "TestPassword123!"
}
)
refresh_token = login_response.json()["refresh_token"]
@@ -334,7 +334,7 @@ class TestGetCurrentUserEndpoint:
"/api/v1/auth/login",
json={
"email": async_test_user.email,
"password": "TestPassword123"
"password": "TestPassword123!"
}
)
access_token = login_response.json()["access_token"]

View File

@@ -148,7 +148,7 @@ class TestPasswordResetConfirm:
"""Test password reset confirmation with valid token."""
# Generate valid token
token = create_password_reset_token(async_test_user.email)
new_password = "NewSecure123"
new_password = "NewSecure123!"
response = await client.post(
"/api/v1/auth/password-reset/confirm",
@@ -186,7 +186,7 @@ class TestPasswordResetConfirm:
"/api/v1/auth/password-reset/confirm",
json={
"token": token,
"new_password": "NewSecure123"
"new_password": "NewSecure123!"
}
)
@@ -204,7 +204,7 @@ class TestPasswordResetConfirm:
"/api/v1/auth/password-reset/confirm",
json={
"token": "invalid_token_xyz",
"new_password": "NewSecure123"
"new_password": "NewSecure123!"
}
)
@@ -233,7 +233,7 @@ class TestPasswordResetConfirm:
"/api/v1/auth/password-reset/confirm",
json={
"token": tampered,
"new_password": "NewSecure123"
"new_password": "NewSecure123!"
}
)
@@ -249,7 +249,7 @@ class TestPasswordResetConfirm:
"/api/v1/auth/password-reset/confirm",
json={
"token": token,
"new_password": "NewSecure123"
"new_password": "NewSecure123!"
}
)
@@ -276,7 +276,7 @@ class TestPasswordResetConfirm:
"/api/v1/auth/password-reset/confirm",
json={
"token": token,
"new_password": "NewSecure123"
"new_password": "NewSecure123!"
}
)
@@ -315,7 +315,7 @@ class TestPasswordResetConfirm:
# Missing token
response = await client.post(
"/api/v1/auth/password-reset/confirm",
json={"new_password": "NewSecure123"}
json={"new_password": "NewSecure123!"}
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@@ -340,7 +340,7 @@ class TestPasswordResetConfirm:
"/api/v1/auth/password-reset/confirm",
json={
"token": token,
"new_password": "NewSecure123"
"new_password": "NewSecure123!"
}
)
@@ -354,7 +354,7 @@ class TestPasswordResetConfirm:
async def test_password_reset_full_flow(self, client, async_test_user, async_test_db):
"""Test complete password reset flow."""
original_password = async_test_user.password_hash
new_password = "BrandNew123"
new_password = "BrandNew123!"
# Step 1: Request password reset
with patch('app.api.routes.auth.email_service.send_password_reset_email') as mock_send:

View File

@@ -40,7 +40,7 @@ class TestListUsers:
@pytest.mark.asyncio
async def test_list_users_as_superuser(self, client, async_test_superuser):
"""Test listing users as superuser."""
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
response = await client.get("/api/v1/users", headers=headers)
@@ -53,7 +53,7 @@ class TestListUsers:
@pytest.mark.asyncio
async def test_list_users_as_regular_user(self, client, async_test_user):
"""Test that regular users cannot list users."""
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
response = await client.get("/api/v1/users", headers=headers)
@@ -77,7 +77,7 @@ class TestListUsers:
session.add(user)
await session.commit()
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
# Get first page
response = await client.get("/api/v1/users?page=1&limit=5", headers=headers)
@@ -111,7 +111,7 @@ class TestListUsers:
session.add_all([active_user, inactive_user])
await session.commit()
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
# Filter for active users
response = await client.get("/api/v1/users?is_active=true", headers=headers)
@@ -130,7 +130,7 @@ class TestListUsers:
@pytest.mark.asyncio
async def test_list_users_sort_by_email(self, client, async_test_superuser):
"""Test sorting users by email."""
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
response = await client.get("/api/v1/users?sort_by=email&sort_order=asc", headers=headers)
assert response.status_code == status.HTTP_200_OK
@@ -154,7 +154,7 @@ class TestGetCurrentUserProfile:
@pytest.mark.asyncio
async def test_get_own_profile(self, client, async_test_user):
"""Test getting own profile."""
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
response = await client.get("/api/v1/users/me", headers=headers)
@@ -176,7 +176,7 @@ class TestUpdateCurrentUser:
@pytest.mark.asyncio
async def test_update_own_profile(self, client, async_test_user):
"""Test updating own profile."""
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
response = await client.patch(
"/api/v1/users/me",
@@ -192,7 +192,7 @@ class TestUpdateCurrentUser:
@pytest.mark.asyncio
async def test_update_profile_phone_number(self, client, async_test_user, test_db):
"""Test updating phone number with validation."""
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
response = await client.patch(
"/api/v1/users/me",
@@ -207,7 +207,7 @@ class TestUpdateCurrentUser:
@pytest.mark.asyncio
async def test_update_profile_invalid_phone(self, client, async_test_user):
"""Test that invalid phone numbers are rejected."""
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
response = await client.patch(
"/api/v1/users/me",
@@ -220,7 +220,7 @@ class TestUpdateCurrentUser:
@pytest.mark.asyncio
async def test_cannot_elevate_to_superuser(self, client, async_test_user):
"""Test that users cannot make themselves superuser."""
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
# Note: is_superuser is not in UserUpdate schema, but the endpoint checks for it
# This tests that even if someone tries to send it, it's rejected
@@ -255,7 +255,7 @@ class TestGetUserById:
@pytest.mark.asyncio
async def test_get_own_profile_by_id(self, client, async_test_user):
"""Test getting own profile by ID."""
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
response = await client.get(f"/api/v1/users/{async_test_user.id}", headers=headers)
@@ -278,7 +278,7 @@ class TestGetUserById:
test_db.commit()
test_db.refresh(other_user)
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
response = await client.get(f"/api/v1/users/{other_user.id}", headers=headers)
@@ -287,7 +287,7 @@ class TestGetUserById:
@pytest.mark.asyncio
async def test_get_other_user_as_superuser(self, client, async_test_superuser, async_test_user):
"""Test that superusers can view other profiles."""
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
response = await client.get(f"/api/v1/users/{async_test_user.id}", headers=headers)
@@ -298,7 +298,7 @@ class TestGetUserById:
@pytest.mark.asyncio
async def test_get_nonexistent_user(self, client, async_test_superuser):
"""Test getting non-existent user."""
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
fake_id = uuid.uuid4()
response = await client.get(f"/api/v1/users/{fake_id}", headers=headers)
@@ -308,7 +308,7 @@ class TestGetUserById:
@pytest.mark.asyncio
async def test_get_user_invalid_uuid(self, client, async_test_superuser):
"""Test getting user with invalid UUID format."""
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
response = await client.get("/api/v1/users/not-a-uuid", headers=headers)
@@ -321,7 +321,7 @@ class TestUpdateUserById:
@pytest.mark.asyncio
async def test_update_own_profile_by_id(self, client, async_test_user, test_db):
"""Test updating own profile by ID."""
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
response = await client.patch(
f"/api/v1/users/{async_test_user.id}",
@@ -348,7 +348,7 @@ class TestUpdateUserById:
test_db.commit()
test_db.refresh(other_user)
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
response = await client.patch(
f"/api/v1/users/{other_user.id}",
@@ -365,7 +365,7 @@ class TestUpdateUserById:
@pytest.mark.asyncio
async def test_update_other_user_as_superuser(self, client, async_test_superuser, async_test_user, test_db):
"""Test that superusers can update other profiles."""
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
response = await client.patch(
f"/api/v1/users/{async_test_user.id}",
@@ -380,7 +380,7 @@ class TestUpdateUserById:
@pytest.mark.asyncio
async def test_regular_user_cannot_modify_superuser_status(self, client, async_test_user):
"""Test that regular users cannot change superuser status even if they try."""
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
# is_superuser not in UserUpdate schema, so it gets ignored by Pydantic
# Just verify the user stays the same
@@ -397,7 +397,7 @@ class TestUpdateUserById:
@pytest.mark.asyncio
async def test_superuser_can_update_users(self, client, async_test_superuser, async_test_user, test_db):
"""Test that superusers can update other users."""
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
response = await client.patch(
f"/api/v1/users/{async_test_user.id}",
@@ -413,7 +413,7 @@ class TestUpdateUserById:
@pytest.mark.asyncio
async def test_update_nonexistent_user(self, client, async_test_superuser):
"""Test updating non-existent user."""
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
fake_id = uuid.uuid4()
response = await client.patch(
@@ -433,14 +433,14 @@ class TestChangePassword:
@pytest.mark.asyncio
async def test_change_password_success(self, client, async_test_user, test_db):
"""Test successful password change."""
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
response = await client.patch(
"/api/v1/users/me/password",
headers=headers,
json={
"current_password": "TestPassword123",
"new_password": "NewPassword123"
"current_password": "TestPassword123!",
"new_password": "NewPassword123!"
}
)
@@ -453,7 +453,7 @@ class TestChangePassword:
"/api/v1/auth/login",
json={
"email": async_test_user.email,
"password": "NewPassword123"
"password": "NewPassword123!"
}
)
assert login_response.status_code == status.HTTP_200_OK
@@ -461,14 +461,14 @@ class TestChangePassword:
@pytest.mark.asyncio
async def test_change_password_wrong_current(self, client, async_test_user):
"""Test that wrong current password is rejected."""
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
response = await client.patch(
"/api/v1/users/me/password",
headers=headers,
json={
"current_password": "WrongPassword123",
"new_password": "NewPassword123"
"new_password": "NewPassword123!"
}
)
@@ -477,13 +477,13 @@ class TestChangePassword:
@pytest.mark.asyncio
async def test_change_password_weak_new_password(self, client, async_test_user):
"""Test that weak new passwords are rejected."""
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
response = await client.patch(
"/api/v1/users/me/password",
headers=headers,
json={
"current_password": "TestPassword123",
"current_password": "TestPassword123!",
"new_password": "weak"
}
)
@@ -496,8 +496,8 @@ class TestChangePassword:
response = await client.patch(
"/api/v1/users/me/password",
json={
"current_password": "TestPassword123",
"new_password": "NewPassword123"
"current_password": "TestPassword123!",
"new_password": "NewPassword123!"
}
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@@ -527,7 +527,7 @@ class TestDeleteUser:
await session.refresh(user_to_delete)
user_id = user_to_delete.id
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
response = await client.delete(f"/api/v1/users/{user_id}", headers=headers)
@@ -545,7 +545,7 @@ class TestDeleteUser:
@pytest.mark.asyncio
async def test_cannot_delete_self(self, client, async_test_superuser):
"""Test that users cannot delete their own account."""
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
response = await client.delete(f"/api/v1/users/{async_test_superuser.id}", headers=headers)
@@ -566,7 +566,7 @@ class TestDeleteUser:
test_db.commit()
test_db.refresh(other_user)
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
response = await client.delete(f"/api/v1/users/{other_user.id}", headers=headers)
@@ -575,7 +575,7 @@ class TestDeleteUser:
@pytest.mark.asyncio
async def test_delete_nonexistent_user(self, client, async_test_superuser):
"""Test deleting non-existent user."""
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
fake_id = uuid.uuid4()
response = await client.delete(f"/api/v1/users/{fake_id}", headers=headers)

View File

@@ -131,7 +131,7 @@ def test_user(test_db):
user = User(
id=uuid.uuid4(),
email="testuser@example.com",
password_hash=get_password_hash("TestPassword123"),
password_hash=get_password_hash("TestPassword123!"),
first_name="Test",
last_name="User",
phone_number="+1234567890",
@@ -155,7 +155,7 @@ def test_superuser(test_db):
user = User(
id=uuid.uuid4(),
email="superuser@example.com",
password_hash=get_password_hash("SuperPassword123"),
password_hash=get_password_hash("SuperPassword123!"),
first_name="Super",
last_name="User",
phone_number="+9876543210",
@@ -181,7 +181,7 @@ async def async_test_user(async_test_db):
user = User(
id=uuid.uuid4(),
email="testuser@example.com",
password_hash=get_password_hash("TestPassword123"),
password_hash=get_password_hash("TestPassword123!"),
first_name="Test",
last_name="User",
phone_number="+1234567890",
@@ -207,7 +207,7 @@ async def async_test_superuser(async_test_db):
user = User(
id=uuid.uuid4(),
email="superuser@example.com",
password_hash=get_password_hash("SuperPassword123"),
password_hash=get_password_hash("SuperPassword123!"),
first_name="Super",
last_name="User",
phone_number="+9876543210",

View File

@@ -24,26 +24,26 @@ class TestPasswordHandling:
def test_password_hash_different_from_password(self):
"""Test that a password hash is different from the original password"""
password = "TestPassword123"
password = "TestPassword123!"
hashed = get_password_hash(password)
assert hashed != password
def test_verify_correct_password(self):
"""Test that verify_password returns True for the correct password"""
password = "TestPassword123"
password = "TestPassword123!"
hashed = get_password_hash(password)
assert verify_password(password, hashed) is True
def test_verify_incorrect_password(self):
"""Test that verify_password returns False for an incorrect password"""
password = "TestPassword123"
wrong_password = "WrongPassword123"
password = "TestPassword123!"
wrong_password = "WrongPassword123!"
hashed = get_password_hash(password)
assert verify_password(wrong_password, hashed) is False
def test_same_password_different_hash(self):
"""Test that the same password gets a different hash each time"""
password = "TestPassword123"
password = "TestPassword123!"
hash1 = get_password_hash(password)
hash2 = get_password_hash(password)
assert hash1 != hash2

View File

@@ -318,7 +318,7 @@ class TestCRUDCreate:
"""Test basic record creation."""
user_data = UserCreate(
email="create@example.com",
password="Password123",
password="Password123!",
first_name="Create",
last_name="Test"
)
@@ -333,7 +333,7 @@ class TestCRUDCreate:
"""Test that creating duplicate email raises error."""
user_data = UserCreate(
email="duplicate@example.com",
password="Password123",
password="Password123!",
first_name="First"
)

View File

@@ -39,7 +39,7 @@ class TestCRUDErrorPaths:
# Create first user
user_data = UserCreate(
email="unique@example.com",
password="Password123",
password="Password123!",
first_name="First"
)
user_crud.create(db_session, obj_in=user_data)
@@ -52,7 +52,7 @@ class TestCRUDErrorPaths:
"""Test create handles other integrity errors."""
user_data = UserCreate(
email="integrityerror@example.com",
password="Password123",
password="Password123!",
first_name="Integrity"
)
@@ -71,7 +71,7 @@ class TestCRUDErrorPaths:
"""Test create handles unexpected errors."""
user_data = UserCreate(
email="unexpectederror@example.com",
password="Password123",
password="Password123!",
first_name="Unexpected"
)

View File

@@ -111,7 +111,7 @@ class TestPhoneNumberValidation:
email="test@example.com",
first_name="Test",
last_name="User",
password="Password123",
password="Password123!",
phone_number="+41791234567"
)
assert user.phone_number == "+41791234567"
@@ -122,6 +122,6 @@ class TestPhoneNumberValidation:
email="test@example.com",
first_name="Test",
last_name="User",
password="Password123",
password="Password123!",
phone_number="invalid-number"
)

View File

@@ -20,7 +20,7 @@ class TestAuthServiceAuthentication:
test_engine, AsyncTestingSessionLocal = async_test_db
# Set a known password for the mock user
password = "TestPassword123"
password = "TestPassword123!"
async with AsyncTestingSessionLocal() as session:
result = await session.execute(select(User).where(User.id == async_test_user.id))
user = result.scalar_one_or_none()
@@ -59,7 +59,7 @@ class TestAuthServiceAuthentication:
test_engine, AsyncTestingSessionLocal = async_test_db
# Set a known password for the mock user
password = "TestPassword123"
password = "TestPassword123!"
async with AsyncTestingSessionLocal() as session:
result = await session.execute(select(User).where(User.id == async_test_user.id))
user = result.scalar_one_or_none()
@@ -82,7 +82,7 @@ class TestAuthServiceAuthentication:
test_engine, AsyncTestingSessionLocal = async_test_db
# Set a known password and make user inactive
password = "TestPassword123"
password = "TestPassword123!"
async with AsyncTestingSessionLocal() as session:
result = await session.execute(select(User).where(User.id == async_test_user.id))
user = result.scalar_one_or_none()
@@ -110,10 +110,10 @@ class TestAuthServiceUserCreation:
user_data = UserCreate(
email="newuser@example.com",
password="TestPassword123",
password="TestPassword123!",
first_name="New",
last_name="User",
phone_number="1234567890"
phone_number="+1234567890"
)
async with AsyncTestingSessionLocal() as session:
@@ -141,7 +141,7 @@ class TestAuthServiceUserCreation:
user_data = UserCreate(
email=async_test_user.email, # Use existing email
password="TestPassword123",
password="TestPassword123!",
first_name="Duplicate",
last_name="User"
)