diff --git a/app/core/config.py b/app/core/config.py index ce42498..f48997b 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -57,8 +57,8 @@ class Settings(BaseSettings): # Face embedding model FACE_EMBEDDING_MODEL_NAME: str = "buffalo_l" - FACE_EMBEDDING_PROVIDERS: str = "CPUExecutionProvider" - FACE_EMBEDDING_CTX_ID: int = -1 + FACE_EMBEDDING_PROVIDERS: str = "CUDAExecutionProvider,CPUExecutionProvider" + FACE_EMBEDDING_CTX_ID: int = 0 FACE_EMBEDDING_DET_WIDTH: int = 640 FACE_EMBEDDING_DET_HEIGHT: int = 640 diff --git a/app/deps/rate_limit.py b/app/deps/rate_limit.py new file mode 100644 index 0000000..c852473 --- /dev/null +++ b/app/deps/rate_limit.py @@ -0,0 +1,25 @@ +from fastapi import Request, HTTPException +from typing import Callable + +from app.infra.redis import RedisClient + +def RateLimiter(requests: int, window: int) -> Callable: + async def _rate_limit_dependency(request: Request) -> None: + client_ip = request.client.host if request.client else "127.0.0.1" + # We can also use user_id if we wanted to rate-limit per user, but IP is general. + # For simplicity, IP based rate limit on the endpoint + path = request.url.path + key = f"rate_limit:{path}:{client_ip}" + + redis = RedisClient.get_instance() + + # Increment request count + current = await redis.incr(key) + if current == 1: + # Set expiry for the window if it's the first request + await redis.expire(key, window) + + if current > requests: + raise HTTPException(status_code=429, detail="Too Many Requests") + + return _rate_limit_dependency diff --git a/app/infra/redis.py b/app/infra/redis.py index acfaa28..6485255 100644 --- a/app/infra/redis.py +++ b/app/infra/redis.py @@ -64,15 +64,15 @@ async def incr(self, key: RedisKey | str) -> int: async def sadd(self, key: RedisKey | str, *values: str) -> int: - result = self._client.sadd(key, *values) + result = await self._client.sadd(key, *values) # type: ignore[misc] return int(cast(int, result)) async def sismember(self, key: RedisKey | str, value: str) -> bool: - result = self._client.sismember(key, value) + result = await self._client.sismember(key, value) # type: ignore[misc] return int(cast(int, result)) == 1 async def srem(self, key: RedisKey | str, *values: str) -> int: - result = self._client.srem(key, *values) + result = await self._client.srem(key, *values) # type: ignore[misc] return int(cast(int, result)) diff --git a/app/router/mobile/photos.py b/app/router/mobile/photos.py index 01bdfe6..00113f9 100644 --- a/app/router/mobile/photos.py +++ b/app/router/mobile/photos.py @@ -6,11 +6,12 @@ from app.container import Container, get_container from app.deps.token_auth import MobileUserSchema, get_current_mobile_user +from app.deps.rate_limit import RateLimiter router = APIRouter(prefix="/photos") -@router.get("") +@router.get("", dependencies=[Depends(RateLimiter(requests=20, window=60))]) async def list_my_photos( event_id: UUID | None = Query(default=None), sort: Literal["asc", "desc"] = Query(default="desc"), diff --git a/app/service/staff_drive.py b/app/service/staff_drive.py index fb83da5..da602cd 100644 --- a/app/service/staff_drive.py +++ b/app/service/staff_drive.py @@ -1,3 +1,4 @@ +import asyncio import base64 import hashlib import json @@ -238,44 +239,47 @@ async def import_images_from_drive( access_token = await self.get_access_token_for_staff_user(staff_user.id) bucket = ImageBucket(f"{DRIVE_BUCKET_PREFIX}/{staff_user.id}") - results: list[DriveImportResult] = [] + semaphore = asyncio.Semaphore(10) - for selected in selected_files: + async def process_file(selected: SelectedDriveFile) -> DriveImportResult: if selected.mime_type and selected.mime_type not in IMAGE_ALLOWED_TYPES: raise AppException.bad_request( f"File '{selected.name}' has unsupported type '{selected.mime_type}'. " f"Allowed: {', '.join(sorted(IMAGE_ALLOWED_TYPES))}" ) - download = await GoogleDriveClient.download_file( - access_token=access_token, - file_id=selected.id, - ) - - if len(download.content) > MAX_IMPORT_FILE_SIZE_BYTES: - raise AppException.bad_request( - f"File '{selected.name}' exceeds the 20 MB size limit" + async with semaphore: + download = await GoogleDriveClient.download_file( + access_token=access_token, + file_id=selected.id, ) - object_name = self._generate_object_name(selected.name) - content_type = selected.mime_type or download.metadata.mime_type + if len(download.content) > MAX_IMPORT_FILE_SIZE_BYTES: + raise AppException.bad_request( + f"File '{selected.name}' exceeds the 20 MB size limit" + ) - await bucket.put_bytes( - data=download.content, - object_name=object_name, - content_type=content_type, - filename=selected.name, - ) + object_name = self._generate_object_name(selected.name) + content_type = selected.mime_type or download.metadata.mime_type - results.append(DriveImportResult( + await bucket.put_bytes( + data=download.content, + object_name=object_name, + content_type=content_type, + filename=selected.name, + ) + + return DriveImportResult( drive_file_id=selected.id, original_file_name=selected.name, minio_bucket=bucket.bucket_name, minio_object_name=object_name, minio_object_path=f"{bucket.file_prefix}/{object_name}", - )) + ) - return results + tasks = [process_file(selected) for selected in selected_files] + results = await asyncio.gather(*tasks) + return list(results) def _fernet(self) -> Fernet: digest = hashlib.sha256(settings.encryption_key.encode("utf-8")).digest() diff --git a/app/service/staff_user.py b/app/service/staff_user.py index 6241818..3a589e4 100644 --- a/app/service/staff_user.py +++ b/app/service/staff_user.py @@ -24,8 +24,9 @@ async def create_staff_user( ) -> StaffUser: try: hashed_password = hash_password(password) + normalized_email = email.strip().lower() if email else None user = await self.staff_user_querier.create_multi( - email=email, + email=normalized_email, password=hashed_password, role=role, ) @@ -108,10 +109,10 @@ async def admin_login( email: str, password: str, ) -> WebAuthResponse: - print("hello") - staff: StaffUser | None = await self.staff_user_querier.get_staff_user_by_email(email=email) + normalized_email = email.strip().lower() + staff: StaffUser | None = await self.staff_user_querier.get_staff_user_by_email(email=normalized_email) if staff is None or not verify_password(password, staff.password): - logger.info("admin login failed for email %s", email) + logger.info("admin login failed for email %s", normalized_email) raise AppException.unauthorized("Invalid email or password") @@ -126,7 +127,7 @@ async def admin_login( role=staff.role, ) - async def Get_stuff_user( + async def get_staff_user( self, stuff_id:uuid.UUID )->StaffUser: diff --git a/app/service/user_notification.py b/app/service/user_notification.py index a269ff1..f8181bc 100644 --- a/app/service/user_notification.py +++ b/app/service/user_notification.py @@ -1,3 +1,4 @@ +import json from typing import Any import uuid @@ -41,7 +42,7 @@ async def create_notification( notification_record = await self.notification_querier.create_notification( user_id=user_id, type=type, - payload=payload, + payload=json.dumps(payload), ) if notification_record is None: raise AppException.internal_error("Failed to create user notification") diff --git a/db/generated/photo_faces.py b/db/generated/photo_faces.py index 507e6c6..6578ef8 100644 --- a/db/generated/photo_faces.py +++ b/db/generated/photo_faces.py @@ -79,6 +79,7 @@ class InsertPhotoFaceWithApprovalParams: inserted_match AS ( INSERT INTO face_matches (photo_face_id, user_id, confidence) SELECT upserted_photo_face.id, :p5, :p6 + FROM upserted_photo_face WHERE NOT EXISTS (SELECT 1 FROM existing_match) RETURNING id ) diff --git a/db/queries/photo_faces.sql b/db/queries/photo_faces.sql index a6286d9..5fab5ce 100644 --- a/db/queries/photo_faces.sql +++ b/db/queries/photo_faces.sql @@ -66,6 +66,7 @@ existing_match AS ( inserted_match AS ( INSERT INTO face_matches (photo_face_id, user_id, confidence) SELECT upserted_photo_face.id, $5, $6 + FROM upserted_photo_face WHERE NOT EXISTS (SELECT 1 FROM existing_match) RETURNING id ) diff --git a/tests/e2e/test_mobile_auth_intent_e2e.py b/tests/e2e/test_mobile_auth_intent_e2e.py deleted file mode 100644 index 4d56978..0000000 --- a/tests/e2e/test_mobile_auth_intent_e2e.py +++ /dev/null @@ -1,183 +0,0 @@ -import os -import uuid -import requests # type: ignore[import-untyped] -import pytest - - -@pytest.mark.skipif( - not os.getenv("MULTAI_RUN_E2E"), - reason="E2E disabled (set MULTAI_RUN_E2E=1 to run)", -) -class TestMobileAuthEndpointsE2E: - """End-to-end tests for mobile auth register/login endpoints. - - These tests run against a live API. Set MULTAI_RUN_E2E=1 and - MULTAI_E2E_BASE_URL=http://localhost:8000 to enable. - """ - - @pytest.fixture(autouse=True) - def setup(self) -> None: - self.base_url = os.getenv("MULTAI_E2E_BASE_URL", "http://localhost:8000") - self.headers = { - "X-Forwarded-For": f"203.0.113.{uuid.uuid4().int % 250 + 1}", - } - - def test_login_with_unknown_email_fails(self) -> None: - """Test that login with unknown email returns 401.""" - payload = { - "email": f"nonexistent_{uuid.uuid4()}@example.com", - "password": "anypassword", - "device_name": "TestDevice", - "device_type": "android", - "device_id": str(uuid.uuid4()), - } - response = requests.post( - f"{self.base_url}/user/auth/login", - json=payload, - headers=self.headers, - ) - assert response.status_code == 401 - assert "not found" in response.json()["detail"].lower() - - def test_register_with_existing_email_fails(self) -> None: - """Test that registration with existing email returns 409.""" - email = f"testuser_{uuid.uuid4()}@example.com" - device_id = str(uuid.uuid4()) - - # First registration succeeds - register_payload = { - "email": email, - "password": "ValidPass@123", - "device_name": "TestDevice", - "device_type": "android", - "device_id": device_id, - } - response1 = requests.post( - f"{self.base_url}/user/auth/register", - json=register_payload, - headers=self.headers, - ) - assert response1.status_code == 200 - assert response1.json()["is_new_user"] is True - - # Second registration with same email fails - response2 = requests.post( - f"{self.base_url}/user/auth/register", - json=register_payload, - headers=self.headers, - ) - assert response2.status_code == 409 - assert "already" in response2.json()["detail"].lower() - - def test_register_then_login_succeeds(self) -> None: - """Test full flow: register then login.""" - email = f"user_{uuid.uuid4()}@example.com" - password = "ValidPass@123" - device_id = str(uuid.uuid4()) - - # Register - register_payload = { - "email": email, - "password": password, - "device_name": "TestDevice", - "device_type": "android", - "device_id": device_id, - } - register_response = requests.post( - f"{self.base_url}/user/auth/register", - json=register_payload, - headers=self.headers, - ) - assert register_response.status_code == 200 - assert register_response.json()["is_new_user"] is True - register_token = register_response.json()["access_token"] - - # Login with same credentials - login_payload = { - "email": email, - "password": password, - "device_name": "TestDevice", - "device_type": "android", - "device_id": device_id, - } - login_response = requests.post( - f"{self.base_url}/user/auth/login", - json=login_payload, - headers=self.headers, - ) - assert login_response.status_code == 200 - assert login_response.json()["is_new_user"] is False - login_token = login_response.json()["access_token"] - - # Both tokens should work - assert register_token - assert login_token - - def test_login_with_wrong_password_fails(self) -> None: - """Test that login with wrong password returns 401.""" - email = f"user_{uuid.uuid4()}@example.com" - password = "CorrectPass@123" - device_id = str(uuid.uuid4()) - - # Register first - register_payload = { - "email": email, - "password": password, - "device_name": "TestDevice", - "device_type": "android", - "device_id": device_id, - } - register_response = requests.post( - f"{self.base_url}/user/auth/register", - json=register_payload, - headers=self.headers, - ) - assert register_response.status_code == 200 - - # Try to login with wrong password - login_payload = { - "email": email, - "password": "WrongPass@123", - "device_name": "TestDevice", - "device_type": "android", - "device_id": device_id, - } - response = requests.post( - f"{self.base_url}/user/auth/login", - json=login_payload, - headers=self.headers, - ) - assert response.status_code == 401 - assert "invalid" in response.json()["detail"].lower() - - def test_register_requires_password(self) -> None: - """Test that register requires a password.""" - payload = { - "email": "user@example.com", - "device_name": "TestDevice", - "device_type": "android", - "device_id": str(uuid.uuid4()), - # Missing password - } - response = requests.post( - f"{self.base_url}/user/auth/register", - json=payload, - headers=self.headers, - ) - assert response.status_code == 422 - - def test_login_requires_device_type(self) -> None: - """Test that login requires device_type.""" - payload = { - "email": "user@example.com", - "password": "ValidPass@123", - "device_name": "TestDevice", - "device_id": str(uuid.uuid4()), - # Missing device_type - } - response = requests.post( - f"{self.base_url}/user/auth/login", - json=payload, - headers=self.headers, - ) - assert response.status_code == 422 diff --git a/tests/e2e/test_mobile_auth_request_validation_e2e.py b/tests/e2e/test_mobile_auth_request_validation_e2e.py deleted file mode 100644 index 79907c0..0000000 --- a/tests/e2e/test_mobile_auth_request_validation_e2e.py +++ /dev/null @@ -1,55 +0,0 @@ -import os -import uuid - -import httpx -import pytest - - -pytestmark = [ - pytest.mark.e2e, - pytest.mark.skipif( - os.getenv("MULTAI_RUN_E2E") != "1", - reason="set MULTAI_RUN_E2E=1 to run live e2e tests", - ), -] - - -BASE_URL = os.getenv("MULTAI_E2E_BASE_URL", "http://localhost:8000").rstrip("/") -REGISTER_URL = f"{BASE_URL}/user/auth/register" -LOGIN_URL = f"{BASE_URL}/user/auth/login" - - -def _valid_payload() -> dict[str, object]: - return { - "email": f"e2e-{uuid.uuid4()}@example.com", - "password": "ValidPass@123", - "device_name": "Pixel 8", - "device_type": "android", - "device_id": str(uuid.uuid4()), - } - - -@pytest.mark.parametrize("field", ["password", "device_name", "device_type"]) -@pytest.mark.parametrize("value", ["", " "]) -@pytest.mark.parametrize("url", [REGISTER_URL, LOGIN_URL]) -def test_live_register_login_rejects_empty_required_text_fields( - field: str, - value: str, - url: str, -) -> None: - payload = _valid_payload() - payload[field] = value - - response = httpx.post(url, json=payload, timeout=10.0) - - assert response.status_code == 422 - - -@pytest.mark.parametrize("url", [REGISTER_URL, LOGIN_URL]) -def test_live_register_login_rejects_padded_short_password(url: str) -> None: - payload = _valid_payload() - payload["password"] = " a" - - response = httpx.post(url, json=payload, timeout=10.0) - - assert response.status_code == 422 diff --git a/tests/unit/test_bcrypt_truncation.py b/tests/unit/test_bcrypt_truncation.py deleted file mode 100644 index d0e994c..0000000 --- a/tests/unit/test_bcrypt_truncation.py +++ /dev/null @@ -1,20 +0,0 @@ -from app.core.securite import hash_password, verify_password - - -def test_bcrypt_72_byte_truncation_is_prevented() -> None: - # Create two passwords that are longer than 72 bytes and share the same 72-byte prefix - prefix = "a" * 72 - pass1 = prefix + "X" - pass2 = prefix + "Y" - - # Hash both passwords - hash1 = hash_password(pass1) - hash2 = hash_password(pass2) - - # They must produce different hashes (or at least pass2 must not verify with hash1) - assert not verify_password(pass2, hash1) - assert not verify_password(pass1, hash2) - - # Each must verify with its own hash - assert verify_password(pass1, hash1) - assert verify_password(pass2, hash2) diff --git a/tests/unit/test_mobile_auth_email_logging.py b/tests/unit/test_mobile_auth_email_logging.py deleted file mode 100644 index 696e095..0000000 --- a/tests/unit/test_mobile_auth_email_logging.py +++ /dev/null @@ -1,139 +0,0 @@ - -# Test doubles intentionally implement only the AuthService methods exercised here. -# They do not subclass the generated queriers, so mypy would otherwise flag each -# constructor injection as an arg-type mismatch. -# mypy: disable-error-code=arg-type - -import asyncio -import logging -import uuid -from datetime import datetime, timezone - -import pytest - -import app.service.users as users_module -from app.schema.request.mobile.auth import MobileRegisterRequest -from app.service.session import SessionService -from app.service.users import AuthService - - -class FakeUser: - def __init__(self, email: str) -> None: - self.id = uuid.uuid4() - self.email = email - self.blocked = False - self.hashed_password = "hashed" - - -class FakeDevice: - is_invalid_token = False - is_active = True - - -class FakeSession: - def __init__(self) -> None: - self.id = uuid.uuid4() - self.expires_at = datetime.now(timezone.utc) - - -class FakeUserQuerier: - def __init__(self, user: FakeUser) -> None: - self._user = user - - async def get_user_by_email(self, email: str) -> FakeUser | None: - return None - - async def create_user(self, *, email: str, hashed_password: str) -> FakeUser: - self._user.email = email - self._user.hashed_password = hashed_password - return self._user - - -class FakeDeviceQuerier: - async def get_device_by_id(self, id: uuid.UUID) -> FakeDevice | None: - return None - - async def create_device(self, arg: object) -> FakeDevice: - return FakeDevice() - - -class FakeSessionQuerier: - def __init__(self, session: FakeSession) -> None: - self._session = session - - async def count_user_sessions(self, user_id: uuid.UUID) -> int: - return 0 - - async def upsert_session( - self, - *, - user_id: uuid.UUID, - device_id: uuid.UUID, - expires_at: datetime, - ) -> FakeSession: - self._session.expires_at = expires_at - return self._session - - -class FakeRedis: - def __init__(self) -> None: - self._store: dict[str, int] = {} - - async def incr(self, key: str) -> int: - self._store[key] = self._store.get(key, 0) + 1 - return self._store[key] - - async def expire(self, key: str, seconds: int) -> None: - pass - - async def ttl(self, key: str) -> int: - return -1 - - async def set(self, key: str, value: str, expire: int) -> None: - return None - -class FakeFaceEmbeddingService: - pass - - -def test_mobile_register_logs_without_plaintext_email( - caplog: pytest.LogCaptureFixture, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Verify that plaintext email is never logged; use user_id instead.""" - caplog.set_level(logging.INFO, logger="multAI") - - user = FakeUser(email="user@example.com") - session = FakeSession() - service = AuthService( - user_querier=FakeUserQuerier(user), - device_querier=FakeDeviceQuerier(), - session_querier=FakeSessionQuerier(session), - face_embedding_service=FakeFaceEmbeddingService(), - ) - - req = MobileRegisterRequest( - email="USER@Example.COM", - password="ValidPass@123", - device_name="Pixel 8", - device_type="android", - device_id=uuid.uuid4(), - ) - - async def _noop_cache_session_for_auth(**_: object) -> None: - return None - - monkeypatch.setattr(SessionService, "cache_session_for_auth", _noop_cache_session_for_auth) - monkeypatch.setattr(users_module, "create_acces_mobile_token", lambda _: "access") - monkeypatch.setattr(users_module, "create_refresh_mobile_token", lambda _: "refresh") - monkeypatch.setattr(users_module, "Get_expiry_time", lambda: 3600) - - asyncio.run(service.mobile_register(FakeRedis(), req)) - - # Verify no plaintext email in logs - assert req.email not in caplog.text - assert "user@example.com" not in caplog.text - # Verify user_id is logged instead - assert "user_id=" in caplog.text - assert "session_id=" in caplog.text - diff --git a/tests/unit/test_mobile_auth_intent_validation.py b/tests/unit/test_mobile_auth_intent_validation.py deleted file mode 100644 index 0f81e97..0000000 --- a/tests/unit/test_mobile_auth_intent_validation.py +++ /dev/null @@ -1,400 +0,0 @@ -from typing import Any - -# Test doubles intentionally implement only the AuthService methods exercised here. -# They do not subclass the generated queriers, so mypy would otherwise flag each -# constructor injection as an arg-type mismatch. -# mypy: disable-error-code=arg-type - -import asyncio -import logging -import uuid -from datetime import datetime, timezone - -import pytest -from fastapi import HTTPException - -import app.service.users as users_module -from app.core.securite import hash_password -from app.schema.request.mobile.auth import MobileLoginRequest, MobileRegisterRequest -from app.service.session import SessionService -from app.service.users import AuthService - - -class FakeUser: - def __init__(self, email: str, exists: bool = True, password: str = "ValidPass@123") -> None: - self.id = uuid.uuid4() - self.email = email - self.blocked = False - self.hashed_password = hash_password(password) - self.exists = exists - - -class FakeDevice: - is_invalid_token = False - is_active = True - - -class FakeSession: - def __init__(self) -> None: - self.id = uuid.uuid4() - self.expires_at = datetime.now(timezone.utc) - - -class FakeUserQuerier: - def __init__(self, user: FakeUser) -> None: - self._user = user - self._created_users: dict[str, FakeUser] = {} - - async def get_user_by_email(self, email: str) -> FakeUser | None: - if self._user.exists and self._user.email == email: - return self._user - if email in self._created_users: - return self._created_users[email] - return None - - async def get_user_by_id(self, id: uuid.UUID) -> FakeUser | None: - if self._user.id == id: - return self._user - return None - - async def create_user(self, *, email: str, hashed_password: str) -> FakeUser: - new_user = FakeUser(email=email, exists=True) - new_user.hashed_password = hashed_password - self._created_users[email] = new_user - return new_user - - -class FakeDeviceQuerier: - async def get_device_by_id(self, id: uuid.UUID) -> FakeDevice | None: - return None - - async def create_device(self, arg: object) -> FakeDevice: - return FakeDevice() - - async def activate_device(self, id: uuid.UUID, user_id: uuid.UUID) -> None: - return None - - -class FakeSessionQuerier: - def __init__(self, session: FakeSession) -> None: - self._session = session - - async def count_user_sessions(self, user_id: uuid.UUID) -> int: - return 0 - - async def get_session_by_id(self, id: uuid.UUID) -> FakeSession | None: - return self._session - - async def upsert_session( - self, - *, - user_id: uuid.UUID, - device_id: uuid.UUID, - expires_at: datetime, - ) -> FakeSession: - self._session.expires_at = expires_at - return self._session - - -class FakeRedis: - def __init__(self) -> None: - self._store: dict[str, int] = {} - - async def incr(self, key: str) -> int: - self._store[key] = self._store.get(key, 0) + 1 - return self._store[key] - - async def expire(self, key: str, seconds: int) -> None: - pass - - async def ttl(self, key: str) -> int: - return -1 - - async def set(self, key: str, value: str, expire: int) -> None: - return None - - -class FakeFaceEmbeddingService: - pass - - -def test_login_with_unknown_email_is_rejected() -> None: - """Test that login with unknown email fails.""" - user = FakeUser(email="user@example.com", exists=True) - session = FakeSession() - service = AuthService( - user_querier=FakeUserQuerier(user), - device_querier=FakeDeviceQuerier(), - session_querier=FakeSessionQuerier(session), - face_embedding_service=FakeFaceEmbeddingService(), - ) - - req = MobileLoginRequest( - email="unknown@example.com", - password="ValidPass@123", - device_name="Pixel 8", - device_type="android", - device_id=uuid.uuid4(), - ) - - with pytest.raises(HTTPException) as exc_info: - asyncio.run(service.mobile_login(FakeRedis(), req)) - assert exc_info.value.status_code == 401 - assert "not found" in exc_info.value.detail.lower() - - -def test_register_with_existing_email_is_rejected() -> None: - """Test that registration with existing email fails.""" - user = FakeUser(email="user@example.com", exists=True) - session = FakeSession() - service = AuthService( - user_querier=FakeUserQuerier(user), - device_querier=FakeDeviceQuerier(), - session_querier=FakeSessionQuerier(session), - face_embedding_service=FakeFaceEmbeddingService(), - ) - - req = MobileRegisterRequest( - email="user@example.com", - password="ValidPass@123", - device_name="Pixel 8", - device_type="android", - device_id=uuid.uuid4(), - ) - - with pytest.raises(HTTPException) as exc_info: - asyncio.run(service.mobile_register(FakeRedis(), req)) - assert exc_info.value.status_code == 409 - assert "already" in exc_info.value.detail.lower() - - -def test_login_with_correct_credentials_succeeds( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Test that login with correct credentials succeeds.""" - user = FakeUser(email="user@example.com", exists=True) - session = FakeSession() - service = AuthService( - user_querier=FakeUserQuerier(user), - device_querier=FakeDeviceQuerier(), - session_querier=FakeSessionQuerier(session), - face_embedding_service=FakeFaceEmbeddingService(), - ) - - req = MobileLoginRequest( - email="user@example.com", - password="ValidPass@123", - device_name="Pixel 8", - device_type="android", - device_id=uuid.uuid4(), - ) - - async def _noop_cache_session_for_auth(**_: object) -> None: - return None - - monkeypatch.setattr(SessionService, "cache_session_for_auth", _noop_cache_session_for_auth) - monkeypatch.setattr(users_module, "create_acces_mobile_token", lambda _: "access") - monkeypatch.setattr(users_module, "create_refresh_mobile_token", lambda _: "refresh") - monkeypatch.setattr(users_module, "Get_expiry_time", lambda: 3600) - - result = asyncio.run(service.mobile_login(FakeRedis(), req)) - assert result.access_token == "access" - assert result.refresh_token == "refresh" - assert result.is_new_user is False - - -def test_register_with_new_email_succeeds( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Test that registration with new email succeeds.""" - user = FakeUser(email="user@example.com", exists=False) - session = FakeSession() - service = AuthService( - user_querier=FakeUserQuerier(user), - device_querier=FakeDeviceQuerier(), - session_querier=FakeSessionQuerier(session), - face_embedding_service=FakeFaceEmbeddingService(), - ) - - req = MobileRegisterRequest( - email="newuser@example.com", - password="ValidPass@123", - device_name="Pixel 8", - device_type="android", - device_id=uuid.uuid4(), - ) - - async def _noop_cache_session_for_auth(**_: object) -> None: - return None - - monkeypatch.setattr(SessionService, "cache_session_for_auth", _noop_cache_session_for_auth) - monkeypatch.setattr(users_module, "create_acces_mobile_token", lambda _: "access") - monkeypatch.setattr(users_module, "create_refresh_mobile_token", lambda _: "refresh") - monkeypatch.setattr(users_module, "Get_expiry_time", lambda: 3600) - - result = asyncio.run(service.mobile_register(FakeRedis(), req)) - assert result.access_token == "access" - assert result.refresh_token == "refresh" - assert result.is_new_user is True - - -def test_register_then_login_same_device_succeeds( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Test full flow: register then login with same device.""" - user = FakeUser(email="newuser@example.com", exists=False) - session = FakeSession() - service = AuthService( - user_querier=FakeUserQuerier(user), - device_querier=FakeDeviceQuerier(), - session_querier=FakeSessionQuerier(session), - face_embedding_service=FakeFaceEmbeddingService(), - ) - - async def _noop_cache_session_for_auth(**_: object) -> None: - return None - - monkeypatch.setattr(SessionService, "cache_session_for_auth", _noop_cache_session_for_auth) - monkeypatch.setattr(users_module, "create_acces_mobile_token", lambda _: "access") - monkeypatch.setattr(users_module, "create_refresh_mobile_token", lambda _: "refresh") - monkeypatch.setattr(users_module, "Get_expiry_time", lambda: 3600) - - device_id = uuid.uuid4() - password = "ValidPass@123" - - # Register - register_req = MobileRegisterRequest( - email="newuser@example.com", - password=password, - device_name="TestDevice", - device_type="android", - device_id=device_id, - ) - result1 = asyncio.run(service.mobile_register(FakeRedis(), register_req)) - assert result1.is_new_user is True - - # Try to register again (should fail) - with pytest.raises(HTTPException) as exc_info: - asyncio.run(service.mobile_register(FakeRedis(), register_req)) - assert exc_info.value.status_code == 409 - - # Now login - login_req = MobileLoginRequest( - email="newuser@example.com", - password=password, - device_name="TestDevice", - device_type="android", - device_id=device_id, - ) - result2 = asyncio.run(service.mobile_login(FakeRedis(), login_req)) - assert result2.is_new_user is False - - -def test_login_with_wrong_password_fails() -> None: - """Test that login with wrong password fails.""" - user = FakeUser(email="user@example.com", exists=True) - session = FakeSession() - service = AuthService( - user_querier=FakeUserQuerier(user), - device_querier=FakeDeviceQuerier(), - session_querier=FakeSessionQuerier(session), - face_embedding_service=FakeFaceEmbeddingService(), - ) - - req = MobileLoginRequest( - email="user@example.com", - password="wrongpassword", - device_name="Pixel 8", - device_type="android", - device_id=uuid.uuid4(), - ) - - with pytest.raises(HTTPException) as exc_info: - asyncio.run(service.mobile_login(FakeRedis(), req)) - assert exc_info.value.status_code == 401 - assert "invalid" in exc_info.value.detail.lower() - - -def test_login_logs_correctly( - caplog: pytest.LogCaptureFixture, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Test that login emits audit-friendly logs without email.""" - caplog.set_level(logging.INFO, logger="multAI") - - user = FakeUser(email="user@example.com", exists=True) - session = FakeSession() - service = AuthService( - user_querier=FakeUserQuerier(user), - device_querier=FakeDeviceQuerier(), - session_querier=FakeSessionQuerier(session), - face_embedding_service=FakeFaceEmbeddingService(), - ) - - req = MobileLoginRequest( - email="USER@Example.COM", - password="ValidPass@123", - device_name="Pixel 8", - device_type="android", - device_id=uuid.uuid4(), - ) - - async def _noop_cache_session_for_auth(**_: object) -> None: - return None - - monkeypatch.setattr(SessionService, "cache_session_for_auth", _noop_cache_session_for_auth) - monkeypatch.setattr(users_module, "create_acces_mobile_token", lambda _: "access") - monkeypatch.setattr(users_module, "create_refresh_mobile_token", lambda _: "refresh") - monkeypatch.setattr(users_module, "Get_expiry_time", lambda: 3600) - - asyncio.run(service.mobile_login(FakeRedis(), req)) - - assert "mobile_login attempt" in caplog.text - assert "login success user_id=" in caplog.text - assert "session_id=" in caplog.text - assert "user@example.com" not in caplog.text - - -def test_register_concurrent_signup_integrity_error() -> None: - """Test that concurrent signup IntegrityError is caught and raised as 409.""" - from sqlalchemy.exc import IntegrityError - - class FakeOrigException(Exception): - sqlstate = "23505" - constraint_name = "idx_users_email" - - user = FakeUser(email="user@example.com", exists=False) - session = FakeSession() - - async def _raise_integrity_error(*args: Any, **kwargs: Any) -> Any: - raise IntegrityError( - statement="INSERT INTO users", - params={}, - orig=FakeOrigException("duplicate key value violates unique constraint idx_users_email") - ) - - user_querier = FakeUserQuerier(user) - # Stub create_user to raise IntegrityError - user_querier.create_user = _raise_integrity_error # type: ignore - - service = AuthService( - user_querier=user_querier, - device_querier=FakeDeviceQuerier(), - session_querier=FakeSessionQuerier(session), - face_embedding_service=FakeFaceEmbeddingService(), - ) - - req = MobileRegisterRequest( - email="newuser@example.com", - password="ValidPass@123", - device_name="Pixel 8", - device_type="android", - device_id=uuid.uuid4(), - ) - - with pytest.raises(HTTPException) as exc_info: - asyncio.run(service.mobile_register(FakeRedis(), req)) - - assert exc_info.value.status_code == 409 - assert "already in use" in exc_info.value.detail.lower() - diff --git a/tests/unit/test_mobile_auth_rate_limiting.py b/tests/unit/test_mobile_auth_rate_limiting.py deleted file mode 100644 index e5f227e..0000000 --- a/tests/unit/test_mobile_auth_rate_limiting.py +++ /dev/null @@ -1,101 +0,0 @@ -import asyncio -import uuid -from typing import Any - -import pytest -from fastapi import HTTPException -from app.service.users import AuthService -from app.schema.request.mobile.auth import MobileLoginRequest - -# Test doubles intentionally implement only the AuthService methods exercised here. -# They do not subclass the generated queriers, so mypy would otherwise flag each -# constructor injection as an arg-type mismatch. -# mypy: disable-error-code=arg-type - - -class MockRedis: - def __init__(self) -> None: - self.data: dict[str, int] = {} - self.ttls: dict[str, int] = {} - - async def incr(self, key: str) -> int: - self.data[key] = self.data.get(key, 0) + 1 - return self.data[key] - - async def expire(self, key: str, seconds: int) -> bool: - self.ttls[key] = seconds - return True - - -class FakeUser: - def __init__(self) -> None: - self.id = uuid.uuid4() - self.email = "test@example.com" - from app.core.securite import hash_password - self.hashed_password = hash_password("ValidPass@123") - self.blocked = False - - -class FakeUserQuerier: - async def get_user_by_email(self, email: str) -> FakeUser: - return FakeUser() - - -class FakeDeviceQuerier: - pass - - -class FakeSessionQuerier: - pass - - -class FakeFaceEmbeddingService: - pass - - -def test_rate_limiting_triggered_after_max_attempts() -> None: - # Set up mocks - redis = MockRedis() - service = AuthService( - user_querier=FakeUserQuerier(), - device_querier=FakeDeviceQuerier(), - session_querier=FakeSessionQuerier(), - face_embedding_service=FakeFaceEmbeddingService(), - ) - - # Stub session creation to avoid database / redis dependencies - async def _dummy_create_session(*args: object, **kwargs: object) -> Any: - from app.schema.response.mobile.auth import MobileAuthResponse - return MobileAuthResponse( - access_token="access", - refresh_token="refresh", - session_id=str(uuid.uuid4()), - expires_in=3600, - user_id=uuid.uuid4(), - is_new_user=False, - ) - service._create_mobile_session = _dummy_create_session # type: ignore - - req = MobileLoginRequest( - email="test@example.com", - password="ValidPass@123", - device_name="Pixel 8", - device_type="android", - device_id=uuid.uuid4(), - ) - - # Call mobile_login 5 times (which is the default max limit in settings) - # The first 5 should succeed without raising exceptions - for i in range(5): - res = asyncio.run(service.mobile_login(redis, req, client_ip="127.0.0.1")) - assert res.access_token == "access" - - # The 6th call must trigger the rate limit and raise 429! - with pytest.raises(HTTPException) as exc_info: - asyncio.run(service.mobile_login(redis, req, client_ip="127.0.0.1")) - assert exc_info.value.status_code == 429 - assert "too many requests" in exc_info.value.detail.lower() - - # The ttls for the keys should have been set - assert redis.ttls["rate:ip:127.0.0.1"] == 60 - assert redis.ttls["rate:email:test@example.com"] == 60 diff --git a/tests/unit/test_mobile_auth_request_validation.py b/tests/unit/test_mobile_auth_request_validation.py deleted file mode 100644 index eb61902..0000000 --- a/tests/unit/test_mobile_auth_request_validation.py +++ /dev/null @@ -1,230 +0,0 @@ -import uuid -from collections.abc import Iterator -from typing import Any - -import pytest -from fastapi.testclient import TestClient - -from app.container import get_container -from app.main import app -from app.schema.request.mobile.auth import MobileLoginRequest, MobileRegisterRequest -from app.schema.response.mobile.auth import MobileAuthResponse - - -class FakeAuthService: - def __init__(self) -> None: - self.register_request: MobileRegisterRequest | None = None - self.login_request: MobileLoginRequest | None = None - self.register_client_ip: object = None - self.login_client_ip: object = None - - async def mobile_register( - self, - redis: object, - req: MobileRegisterRequest, - client_ip: object = None, - ) -> MobileAuthResponse: - self.register_request = req - self.register_client_ip = client_ip - return MobileAuthResponse( - access_token="access", - refresh_token="refresh", - session_id=str(uuid.uuid4()), - expires_in=3600, - user_id=uuid.uuid4(), - is_new_user=True, - ) - - async def mobile_login( - self, - redis: object, - req: MobileLoginRequest, - client_ip: object = None, - ) -> MobileAuthResponse: - self.login_request = req - self.login_client_ip = client_ip - return MobileAuthResponse( - access_token="access", - refresh_token="refresh", - session_id=str(uuid.uuid4()), - expires_in=3600, - user_id=uuid.uuid4(), - is_new_user=False, - ) - - -class FakeAuditService: - async def create_record(self, **kwargs: Any) -> None: - return None - - -class FakeContainer: - def __init__(self) -> None: - self.redis = object() - self.auth_service = FakeAuthService() - self.audit_service = FakeAuditService() - - -@pytest.fixture -def fake_container() -> FakeContainer: - return FakeContainer() - - -@pytest.fixture -def client(fake_container: FakeContainer) -> Iterator[TestClient]: - app.dependency_overrides[get_container] = lambda: fake_container - try: - yield TestClient(app) - finally: - app.dependency_overrides.clear() - - -def _valid_payload() -> dict[str, object]: - return { - "email": "USER@Example.COM", - "password": "ValidPass@123", - "device_name": "Pixel 8", - "device_type": "android", - "device_id": str(uuid.uuid4()), - } - - -@pytest.mark.parametrize("field", ["password", "device_name", "device_type"]) -@pytest.mark.parametrize("value", ["", " "]) -def test_register_login_rejects_empty_required_text_fields( - client: TestClient, - fake_container: FakeContainer, - field: str, - value: str, -) -> None: - for endpoint, attr in ( - ("/user/auth/register", "register_request"), - ("/user/auth/login", "login_request"), - ): - payload = _valid_payload() - payload[field] = value - - response = client.post(endpoint, json=payload) - - assert response.status_code == 422 - assert getattr(fake_container.auth_service, attr) is None - - -def test_register_login_passes_normalized_input_to_service( - client: TestClient, - fake_container: FakeContainer, -) -> None: - for endpoint, attr in ( - ("/user/auth/register", "register_request"), - ("/user/auth/login", "login_request"), - ): - payload = _valid_payload() - payload.update( - { - "email": " USER@Example.COM ", - "device_name": " Pixel 8 ", - "device_type": " ANDROID ", - } - ) - - response = client.post(endpoint, json=payload) - - assert response.status_code == 200 - req = getattr(fake_container.auth_service, attr) - assert req is not None - assert req.email == "user@example.com" - assert req.device_name == "Pixel 8" - assert req.device_type == "android" - - -def test_register_login_password_length_is_checked_after_trimming( - client: TestClient, - fake_container: FakeContainer, -) -> None: - for endpoint, attr in ( - ("/user/auth/register", "register_request"), - ("/user/auth/login", "login_request"), - ): - payload = _valid_payload() - payload["password"] = " a" - - response = client.post(endpoint, json=payload) - - assert response.status_code == 422 - assert getattr(fake_container.auth_service, attr) is None - - -def test_register_login_rejects_oversized_email( - client: TestClient, - fake_container: FakeContainer, -) -> None: - for endpoint, attr in ( - ("/user/auth/register", "register_request"), - ("/user/auth/login", "login_request"), - ): - payload = _valid_payload() - # Create an email address with a length > 255 chars - # username part is 250 'a's, domain is "@example.com" - payload["email"] = "a" * 250 + "@example.com" - - response = client.post(endpoint, json=payload) - - assert response.status_code == 422 - assert getattr(fake_container.auth_service, attr) is None - - -def test_register_enforces_password_complexity( - client: TestClient, - fake_container: FakeContainer, -) -> None: - # Valid complex password - payload = _valid_payload() - payload["password"] = "P@ssword123" - response = client.post("/user/auth/register", json=payload) - assert response.status_code == 200 - assert fake_container.auth_service.register_request is not None - - # Invalid passwords lacking different criteria - invalid_passwords = [ - "p@ssword123", # missing uppercase - "P@SSWORD123", # missing lowercase - "P@sswordabc", # missing digit - "Password123", # missing special char - ] - for pw in invalid_passwords: - fake_container.auth_service.register_request = None - payload = _valid_payload() - payload["password"] = pw - response = client.post("/user/auth/register", json=payload) - assert response.status_code == 422 - assert fake_container.auth_service.register_request is None - - -def test_login_does_not_enforce_password_complexity( - client: TestClient, - fake_container: FakeContainer, -) -> None: - # Simple password should still be allowed to attempt log in - payload = _valid_payload() - payload["password"] = "Password123" # missing special character - response = client.post("/user/auth/login", json=payload) - assert response.status_code == 200 - assert fake_container.auth_service.login_request is not None - - -def test_mobile_auth_uses_forwarded_ip_for_rate_limit_identity( - client: TestClient, - fake_container: FakeContainer, -) -> None: - payload = _valid_payload() - - response = client.post( - "/user/auth/login", - json=payload, - headers={"X-Forwarded-For": "203.0.113.10, 10.0.0.1"}, - ) - - assert response.status_code == 200 - assert fake_container.auth_service.login_client_ip == "203.0.113.10" - -