From ee7ace0e9174bc6c773ad344d980b1f18406ebd4 Mon Sep 17 00:00:00 2001 From: kaifcodec Date: Wed, 4 Mar 2026 14:49:42 +0530 Subject: [PATCH 1/8] feat: add password change endpoint with complexity validation --- backend/app/api/routes/v1/auth.py | 25 +++++++++++++++++++++++-- backend/app/schemas/developer.py | 25 ++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/backend/app/api/routes/v1/auth.py b/backend/app/api/routes/v1/auth.py index a7f0dbc1..1b02e627 100644 --- a/backend/app/api/routes/v1/auth.py +++ b/backend/app/api/routes/v1/auth.py @@ -5,7 +5,7 @@ from app.config import settings from app.database import DbSession -from app.schemas import DeveloperRead, DeveloperUpdate +from app.schemas import DeveloperRead, DeveloperUpdate, PasswordChange from app.schemas.token import TokenResponse from app.services import DeveloperDep, developer_service, refresh_token_service from app.utils.security import create_access_token, verify_password @@ -59,7 +59,28 @@ async def logout(_developer: DeveloperDep): return {"message": "Successfully logged out"} -# TODO: Implement /forgot-password and /reset-password +@router.post("/change-password") +async def change_password( + payload: PasswordChange, + db: DbSession, + developer: DeveloperDep, +): + """Change password for the current authenticated developer.""" + # Verify the current password + if not verify_password(payload.current_password, developer.hashed_password): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Incorrect current password", + ) + + # Update using existing developer_service logic + developer_service.update_developer_info( + db, + developer.id, + DeveloperUpdate(password=payload.new_password) + ) + + return {"message": "Password updated successfully"} @router.get("/me", response_model=DeveloperRead) diff --git a/backend/app/schemas/developer.py b/backend/app/schemas/developer.py index 297e1e00..447cc667 100644 --- a/backend/app/schemas/developer.py +++ b/backend/app/schemas/developer.py @@ -1,7 +1,7 @@ from datetime import datetime, timezone from uuid import UUID, uuid4 -from pydantic import BaseModel, ConfigDict, EmailStr, Field +from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator class DeveloperRead(BaseModel): @@ -45,3 +45,26 @@ class DeveloperUpdateInternal(BaseModel): email: EmailStr | None = None hashed_password: str | None = None updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +class PasswordChange(BaseModel): + current_password: str + new_password: str = Field(..., min_length=8) + confirm_password: str + + @field_validator("new_password") + @classmethod + def password_complexity(cls, v: str) -> str: + if not any(char.isdigit() for char in v): + raise ValueError("Password must contain at least one number") + + if not any(char.isalpha() for char in v): + raise ValueError("Password must contain at least one letter") + + return v + + @model_validator(mode="after") + def check_passwords_match(self) -> "PasswordChange": + if self.new_password != self.confirm_password: + raise ValueError("The confirmation password does not match the new password") + return self From 514ce271f6b03c75d19f2dc2a0df61c45d348150 Mon Sep 17 00:00:00 2001 From: kaifcodec Date: Wed, 4 Mar 2026 15:04:33 +0530 Subject: [PATCH 2/8] test: add unit tests for change-password endpoint --- backend/tests/api/v1/test_auth.py | 69 +++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/backend/tests/api/v1/test_auth.py b/backend/tests/api/v1/test_auth.py index 6e9eb199..21239d15 100644 --- a/backend/tests/api/v1/test_auth.py +++ b/backend/tests/api/v1/test_auth.py @@ -145,6 +145,75 @@ def test_logout_invalid_token(self, client: TestClient, api_v1_prefix: str) -> N assert response.status_code == 401 +class TestChangePassword: + """Tests for POST /api/v1/auth/change-password.""" + + def test_change_password_success(self, client: TestClient, db: Session, api_v1_prefix: str) -> None: + """Test successful password change with valid data.""" + # Arrange + developer = DeveloperFactory(password="OldPassword123") + headers = developer_auth_headers(developer.id) + payload = { + "current_password": "OldPassword123", + "new_password": "NewPassword456", + "confirm_password": "NewPassword456" + } + + # Act + response = client.post(f"{api_v1_prefix}/auth/change-password", json=payload, headers=headers) + + # Assert + assert response.status_code == 200 + assert response.json()["message"] == "Password updated successfully" + + def test_change_password_invalid_current(self, client: TestClient, db: Session, api_v1_prefix: str) -> None: + """Test failure when the current password is wrong.""" + developer = DeveloperFactory(password="CorrectOld123") + headers = developer_auth_headers(developer.id) + payload = { + "current_password": "WrongOld123", + "new_password": "NewPassword789", + "confirm_password": "NewPassword789" + } + + response = client.post(f"{api_v1_prefix}/auth/change-password", json=payload, headers=headers) + + assert response.status_code == 400 + assert response.json()["detail"] == "Incorrect current password" + + def test_change_password_complexity_fail(self, client: TestClient, db: Session, api_v1_prefix: str) -> None: + """Test failure when new password lacks numbers (Pydantic validation).""" + developer = DeveloperFactory(password="OldPassword123") + headers = developer_auth_headers(developer.id) + # Missing numbers + payload = { + "current_password": "OldPassword123", + "new_password": "OnlyLetters", + "confirm_password": "OnlyLetters" + } + + response = client.post(f"{api_v1_prefix}/auth/change-password", json=payload, headers=headers) + + assert response.status_code == 422 + # Check if our custom error message is in the response + assert "Password must contain at least one number" in str(response.json()) + + def test_change_password_mismatch(self, client: TestClient, db: Session, api_v1_prefix: str) -> None: + """Test failure when new_password and confirm_password do not match.""" + developer = DeveloperFactory(password="OldPassword123") + headers = developer_auth_headers(developer.id) + payload = { + "current_password": "OldPassword123", + "new_password": "NewPassword123", + "confirm_password": "DifferentPassword123" + } + + response = client.post(f"{api_v1_prefix}/auth/change-password", json=payload, headers=headers) + + assert response.status_code == 422 + assert "The new passwords do not match" in str(response.json()) + + class TestGetCurrentDeveloper: """Tests for GET /api/v1/me.""" From 51a12cf4ad27c2e61df11d282fd75f143e7b83a8 Mon Sep 17 00:00:00 2001 From: kaifcodec Date: Thu, 5 Mar 2026 07:05:08 +0530 Subject: [PATCH 3/8] fix: export PasswordChange schema from app.schemas --- backend/app/schemas/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 4ef4680a..82d62817 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -52,6 +52,7 @@ DeveloperRead, DeveloperUpdate, DeveloperUpdateInternal, + PasswordChange, ) from .device_type import DeviceType from .device_type_priority import ( @@ -215,6 +216,7 @@ "DeveloperCreateInternal", "DeveloperUpdateInternal", "DeveloperUpdate", + "PasswordChange", "InvitationCreate", "InvitationRead", "InvitationAccept", From 36da359bccf2eb3c4d6fd5ec824a5f03ad590dd8 Mon Sep 17 00:00:00 2001 From: kaifcodec Date: Thu, 5 Mar 2026 07:05:20 +0530 Subject: [PATCH 4/8] style: apply ruff formatting to auth routes and tests --- backend/app/api/routes/v1/auth.py | 6 +----- backend/tests/api/v1/test_auth.py | 8 ++++---- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/backend/app/api/routes/v1/auth.py b/backend/app/api/routes/v1/auth.py index 1b02e627..b0f76e2c 100644 --- a/backend/app/api/routes/v1/auth.py +++ b/backend/app/api/routes/v1/auth.py @@ -74,11 +74,7 @@ async def change_password( ) # Update using existing developer_service logic - developer_service.update_developer_info( - db, - developer.id, - DeveloperUpdate(password=payload.new_password) - ) + developer_service.update_developer_info(db, developer.id, DeveloperUpdate(password=payload.new_password)) return {"message": "Password updated successfully"} diff --git a/backend/tests/api/v1/test_auth.py b/backend/tests/api/v1/test_auth.py index 21239d15..fcc9f834 100644 --- a/backend/tests/api/v1/test_auth.py +++ b/backend/tests/api/v1/test_auth.py @@ -156,7 +156,7 @@ def test_change_password_success(self, client: TestClient, db: Session, api_v1_p payload = { "current_password": "OldPassword123", "new_password": "NewPassword456", - "confirm_password": "NewPassword456" + "confirm_password": "NewPassword456", } # Act @@ -173,7 +173,7 @@ def test_change_password_invalid_current(self, client: TestClient, db: Session, payload = { "current_password": "WrongOld123", "new_password": "NewPassword789", - "confirm_password": "NewPassword789" + "confirm_password": "NewPassword789", } response = client.post(f"{api_v1_prefix}/auth/change-password", json=payload, headers=headers) @@ -189,7 +189,7 @@ def test_change_password_complexity_fail(self, client: TestClient, db: Session, payload = { "current_password": "OldPassword123", "new_password": "OnlyLetters", - "confirm_password": "OnlyLetters" + "confirm_password": "OnlyLetters", } response = client.post(f"{api_v1_prefix}/auth/change-password", json=payload, headers=headers) @@ -205,7 +205,7 @@ def test_change_password_mismatch(self, client: TestClient, db: Session, api_v1_ payload = { "current_password": "OldPassword123", "new_password": "NewPassword123", - "confirm_password": "DifferentPassword123" + "confirm_password": "DifferentPassword123", } response = client.post(f"{api_v1_prefix}/auth/change-password", json=payload, headers=headers) From ecb0777e347f73f54762f04a79e67e67decde452 Mon Sep 17 00:00:00 2001 From: kaifcodec Date: Thu, 5 Mar 2026 15:22:34 +0530 Subject: [PATCH 5/8] fix(tests): update status codes to 400 for validation --- backend/tests/api/v1/test_auth.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/tests/api/v1/test_auth.py b/backend/tests/api/v1/test_auth.py index fcc9f834..ea9db1dc 100644 --- a/backend/tests/api/v1/test_auth.py +++ b/backend/tests/api/v1/test_auth.py @@ -194,7 +194,7 @@ def test_change_password_complexity_fail(self, client: TestClient, db: Session, response = client.post(f"{api_v1_prefix}/auth/change-password", json=payload, headers=headers) - assert response.status_code == 422 + assert response.status_code == 400 # Check if our custom error message is in the response assert "Password must contain at least one number" in str(response.json()) @@ -210,7 +210,7 @@ def test_change_password_mismatch(self, client: TestClient, db: Session, api_v1_ response = client.post(f"{api_v1_prefix}/auth/change-password", json=payload, headers=headers) - assert response.status_code == 422 + assert response.status_code == 400 assert "The new passwords do not match" in str(response.json()) From b62472ffe80df83eed2b20058f316144a53ab5d2 Mon Sep 17 00:00:00 2001 From: kaifcodec Date: Sun, 8 Mar 2026 13:19:48 +0530 Subject: [PATCH 6/8] fix(tests): update password mismatch error message to match API output --- backend/tests/api/v1/test_auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/api/v1/test_auth.py b/backend/tests/api/v1/test_auth.py index ea9db1dc..cb4602ac 100644 --- a/backend/tests/api/v1/test_auth.py +++ b/backend/tests/api/v1/test_auth.py @@ -211,7 +211,7 @@ def test_change_password_mismatch(self, client: TestClient, db: Session, api_v1_ response = client.post(f"{api_v1_prefix}/auth/change-password", json=payload, headers=headers) assert response.status_code == 400 - assert "The new passwords do not match" in str(response.json()) + assert "The confirmation password does not match" in str(response.json()) class TestGetCurrentDeveloper: From fef6415a35fe167285095e8470b0256c3f9880f0 Mon Sep 17 00:00:00 2001 From: kaifcodec Date: Wed, 11 Mar 2026 08:30:45 +0530 Subject: [PATCH 7/8] refactor: remove password complexity validation for consistency --- backend/app/schemas/developer.py | 13 +------------ backend/tests/api/v1/test_auth.py | 17 ----------------- 2 files changed, 1 insertion(+), 29 deletions(-) diff --git a/backend/app/schemas/developer.py b/backend/app/schemas/developer.py index 447cc667..8fc15c26 100644 --- a/backend/app/schemas/developer.py +++ b/backend/app/schemas/developer.py @@ -49,20 +49,9 @@ class DeveloperUpdateInternal(BaseModel): class PasswordChange(BaseModel): current_password: str - new_password: str = Field(..., min_length=8) + new_password: str confirm_password: str - @field_validator("new_password") - @classmethod - def password_complexity(cls, v: str) -> str: - if not any(char.isdigit() for char in v): - raise ValueError("Password must contain at least one number") - - if not any(char.isalpha() for char in v): - raise ValueError("Password must contain at least one letter") - - return v - @model_validator(mode="after") def check_passwords_match(self) -> "PasswordChange": if self.new_password != self.confirm_password: diff --git a/backend/tests/api/v1/test_auth.py b/backend/tests/api/v1/test_auth.py index cb4602ac..bad290bb 100644 --- a/backend/tests/api/v1/test_auth.py +++ b/backend/tests/api/v1/test_auth.py @@ -181,23 +181,6 @@ def test_change_password_invalid_current(self, client: TestClient, db: Session, assert response.status_code == 400 assert response.json()["detail"] == "Incorrect current password" - def test_change_password_complexity_fail(self, client: TestClient, db: Session, api_v1_prefix: str) -> None: - """Test failure when new password lacks numbers (Pydantic validation).""" - developer = DeveloperFactory(password="OldPassword123") - headers = developer_auth_headers(developer.id) - # Missing numbers - payload = { - "current_password": "OldPassword123", - "new_password": "OnlyLetters", - "confirm_password": "OnlyLetters", - } - - response = client.post(f"{api_v1_prefix}/auth/change-password", json=payload, headers=headers) - - assert response.status_code == 400 - # Check if our custom error message is in the response - assert "Password must contain at least one number" in str(response.json()) - def test_change_password_mismatch(self, client: TestClient, db: Session, api_v1_prefix: str) -> None: """Test failure when new_password and confirm_password do not match.""" developer = DeveloperFactory(password="OldPassword123") From f9a10512ed4a009525a78180526daf56be17fde5 Mon Sep 17 00:00:00 2001 From: kaifcodec Date: Wed, 11 Mar 2026 18:36:55 +0530 Subject: [PATCH 8/8] fix: ruff fixes unused import --- backend/app/schemas/developer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/schemas/developer.py b/backend/app/schemas/developer.py index 8fc15c26..c5ac7b95 100644 --- a/backend/app/schemas/developer.py +++ b/backend/app/schemas/developer.py @@ -1,7 +1,7 @@ from datetime import datetime, timezone from uuid import UUID, uuid4 -from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, EmailStr, Field, model_validator class DeveloperRead(BaseModel):