diff --git a/.env.example b/.env.example index 0143d348..bb6f6b2b 100644 --- a/.env.example +++ b/.env.example @@ -49,4 +49,8 @@ FACE_ENCRYPTION_KEY=hkbribvfirirbvivbibvib CORS_ORIGINS=["http://localhost:3000", "http://localhost:5173", "http://127.0.0.1:3000", "http://127.0.0.1:5173"] # Firebase -FIREBASE_CREDENTIALS_PATH=path/to/firebase-credentials.json \ No newline at end of file +FIREBASE_CREDENTIALS_PATH=path/to/firebase-credentials.json + +# Resend Email Configuration +RESEND_API_KEY= +EMAIL_FROM= \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5593ad32..16566588 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,9 @@ db.txt .venv multiai-c9380-firebase-adminsdk-fbsvc-cb6e5ce41b.json + +# Local Test & Debug Files +.coverage +dummy.json +test_output.log +*.log diff --git a/app/core/config.py b/app/core/config.py index ce424981..b04e3ef9 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -33,6 +33,8 @@ class Settings(BaseSettings): POSTGRES_HOST: str = "localhost" POSTGRES_PORT: int = 5432 + PHOTO_APPROVAL_TIMEOUT_DAYS: int = 7 + # Mobile auth/session defaults MOBILE_SESSION_LIMIT: int = 3 MOBILE_SESSION_TTL_SECONDS: int = 180 @@ -57,8 +59,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 @@ -73,6 +75,10 @@ class Settings(BaseSettings): FACE_ENCRYPTION_KEY: str FIREBASE_CREDENTIALS_PATH: str + # Resend Email Configuration + RESEND_API_KEY: str = "" + EMAIL_FROM: str = "onboarding@resend.dev" + model_config = SettingsConfigDict( env_file=".env", extra="ignore", diff --git a/app/core/constant.py b/app/core/constant.py index b1ffd281..847c1195 100644 --- a/app/core/constant.py +++ b/app/core/constant.py @@ -22,6 +22,7 @@ class AuditEventType(str, Enum): USER_SIGNUP = "user.signup" USER_LOGIN = "user.login" USER_LOGOUT = "user.logout" + FACE_ENROLLMENT_ATTEMPT = "face_enrollment.attempt" UPLOAD_REQUEST_CREATED = "upload_request.created" UPLOAD_REQUEST_APPROVED = "upload_request.approved" UPLOAD_REQUEST_REJECTED = "upload_request.rejected" @@ -50,5 +51,11 @@ class AuditEventType(str, Enum): GOOGLE_DRIVE_FILES_URL = "https://www.googleapis.com/drive/v3/files/{file_id}" MAX_IMAGE_SIZE = 5 * 1024 * 1024 +MIN_IMAGE_DIM = 64 +MAX_IMAGE_DIM = 4096 MIN_ENROLL_IMAGES = 3 MAX_ENROLL_IMAGES = 5 + +ENROLL_RATE_LIMIT_MAX = 5 +ENROLL_RATE_LIMIT_WINDOW = 3600 +ENROLL_IN_PROGRESS_TTL_SECONDS = 300 diff --git a/app/deps/rate_limit.py b/app/deps/rate_limit.py new file mode 100644 index 00000000..a21e2051 --- /dev/null +++ b/app/deps/rate_limit.py @@ -0,0 +1,36 @@ +from fastapi import Request, HTTPException +from typing import Callable + +from app.infra.redis import RedisClient +from app.core.config import settings + +def _get_client_ip(request: Request) -> str: + if settings.TRUST_PROXY_HEADERS: + forwarded_for = request.headers.get("x-forwarded-for") + if forwarded_for: + return forwarded_for.split(",", maxsplit=1)[0].strip() + real_ip = request.headers.get("x-real-ip") + if real_ip: + return real_ip.strip() + return request.client.host if request.client else "127.0.0.1" + + +def RateLimiter(requests: int, window: int) -> Callable: + async def _rate_limit_dependency(request: Request) -> None: + client_ip = _get_client_ip(request) + # 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/email.py b/app/infra/email.py new file mode 100644 index 00000000..b2beaf55 --- /dev/null +++ b/app/infra/email.py @@ -0,0 +1,61 @@ +import json +import urllib.request +import urllib.error +import asyncio +from app.core.config import settings +from app.core.logger import logger + +class EmailSender: + @staticmethod + async def send_otp_email(to_email: str, otp: str) -> bool: + if not settings.RESEND_API_KEY: + logger.warning("RESEND_API_KEY is not set. Skipping email sending.") + # During development without an API key, just log the OTP + logger.info("MOCK EMAIL to %s: Your OTP is %s", to_email, otp) + return True + + url = "https://api.resend.com/emails" + headers = { + "Authorization": f"Bearer {settings.RESEND_API_KEY}", + "Content-Type": "application/json", + "User-Agent": "multAI-Backend/1.0" + } + + html_content = f""" +
+

Bienvenue sur multAI !

+

Voici votre code de vérification :

+

{otp}

+

Ce code est valide pendant 10 minutes.

+
+ """ + + data = { + "from": settings.EMAIL_FROM, + "to": [to_email], + "subject": "Votre code de vérification multAI", + "html": html_content + } + + req = urllib.request.Request( + url, + data=json.dumps(data).encode("utf-8"), + headers=headers, + method="POST" + ) + + def _send() -> bool: + try: + with urllib.request.urlopen(req, timeout=10) as response: + res_body = response.read() + logger.info("Email sent via Resend. Response: %s", res_body) + return True + except urllib.error.HTTPError as e: + err_body = e.read() + logger.error("Failed to send email via Resend: %s - %s", e.code, err_body) + return False + except Exception as e: + logger.error("Error sending email: %s", str(e)) + return False + + return await asyncio.to_thread(_send) diff --git a/app/infra/redis.py b/app/infra/redis.py index acfaa28d..64852557 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/main.py b/app/main.py index 1e06dcba..34d64d37 100644 --- a/app/main.py +++ b/app/main.py @@ -8,6 +8,7 @@ from fastapi.middleware.cors import CORSMiddleware from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint from app.core.config import settings +from app.infra.database import engine from app.infra.minio import init_minio_client from app.infra.nats import NatsClient from app.infra.redis import RedisClient @@ -48,6 +49,18 @@ async def dispatch( +async def _approval_expiry_loop() -> None: + while True: + await asyncio.sleep(3600) + try: + async with engine.begin() as conn: + from app.container import Container + container = Container(conn) + await container.photo_approval_service.expire_stale(settings.PHOTO_APPROVAL_TIMEOUT_DAYS) + except Exception as exc: + logger.warning("Approval expiry task failed: %s", exc) + + MAX_RETRIES = 5 RETRY_DELAY = 2 # seconds @asynccontextmanager @@ -77,8 +90,12 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: await NatsClient.connect() get_face_embedding_service() + expiry_task = asyncio.create_task(_approval_expiry_loop()) + yield + expiry_task.cancel() + await asyncio.gather(expiry_task, return_exceptions=True) await RedisClient.get_instance().close() await NatsClient.close() diff --git a/app/router/mobile/auth.py b/app/router/mobile/auth.py index 8a9a1a00..01f6c4a2 100644 --- a/app/router/mobile/auth.py +++ b/app/router/mobile/auth.py @@ -7,15 +7,18 @@ from app.core.config import settings from app.core.constant import AuditEventType from app.deps.token_auth import MobileUserSchema, get_current_mobile_user +from app.deps.rate_limit import RateLimiter from app.schema.request.mobile.auth import ( MobileLoginRequest, MobileRegisterRequest, + RegisterVerifyRequest, + ResendOtpRequest, RefreshTokenRequest, UpdateDeviceTokenRequest, InactivateDeviceRequest, ) -from app.schema.response.mobile.auth import MeResponse, DeviceSchema, MobileAuthResponse, SessionSchema, UserSchema +from app.schema.response.mobile.auth import MeResponse, DeviceSchema, MobileAuthResponse, SessionSchema, UserSchema, RegisterPendingResponse router = APIRouter(prefix="/auth") @@ -33,23 +36,45 @@ def _get_client_ip(request: Request) -> str | None: return request.client.host if request.client else None -@router.post("/register", response_model=MobileAuthResponse) +@router.post("/register", response_model=RegisterPendingResponse, dependencies=[Depends(RateLimiter(requests=5, window=60))]) async def mobile_register( req: MobileRegisterRequest, request: Request, container: Container = Depends(get_container), -) -> MobileAuthResponse: +) -> RegisterPendingResponse: client_ip = _get_client_ip(request) result = await container.auth_service.mobile_register(container.redis, req, client_ip=client_ip) + return result + + +@router.post("/register/resend-otp", response_model=RegisterPendingResponse, dependencies=[Depends(RateLimiter(requests=5, window=60))]) +async def mobile_register_resend_otp( + req: ResendOtpRequest, + request: Request, + container: Container = Depends(get_container), +) -> RegisterPendingResponse: + client_ip = _get_client_ip(request) + result = await container.auth_service.mobile_register_resend_otp(container.redis, req.email, client_ip=client_ip) + return result + + +@router.post("/register/verify", response_model=MobileAuthResponse, dependencies=[Depends(RateLimiter(requests=10, window=60))]) +async def mobile_register_verify( + req: RegisterVerifyRequest, + request: Request, + container: Container = Depends(get_container), +) -> MobileAuthResponse: + client_ip = _get_client_ip(request) + result = await container.auth_service.verify_mobile_register(container.redis, req, client_ip=client_ip) await container.audit_service.create_record( event_type=AuditEventType.USER_SIGNUP, user_id=result.user_id, - metadata={"endpoint": "register"}, + metadata={"endpoint": "register_verify"}, ) return result -@router.post("/login", response_model=MobileAuthResponse) +@router.post("/login", response_model=MobileAuthResponse, dependencies=[Depends(RateLimiter(requests=5, window=60))]) async def mobile_login( req: MobileLoginRequest, request: Request, diff --git a/app/router/mobile/enrollement.py b/app/router/mobile/enrollement.py index 1a5f652c..2fcb5307 100644 --- a/app/router/mobile/enrollement.py +++ b/app/router/mobile/enrollement.py @@ -1,75 +1,297 @@ +import re +import time +import uuid +from collections.abc import AsyncIterator +from io import BytesIO from typing import Annotated, List -from fastapi import APIRouter, File, UploadFile, Depends +import filetype # type: ignore[import-untyped] +import pillow_heif # type: ignore[import-untyped] +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile +from fastapi.concurrency import run_in_threadpool +from PIL import Image +from pydantic import BaseModel from app.container import Container, get_container -from app.deps.token_auth import MobileUserSchema, get_current_mobile_user -from app.core.exceptions import AppException from app.core.constant import ( - DEFAULT_CONTENT_TYPE, + ENROLL_IN_PROGRESS_TTL_SECONDS, + AuditEventType, + ENROLL_RATE_LIMIT_MAX, + ENROLL_RATE_LIMIT_WINDOW, IMAGE_ALLOWED_TYPES, MAX_ENROLL_IMAGES, MAX_IMAGE_SIZE, + MAX_IMAGE_DIM, MIN_ENROLL_IMAGES, + MIN_IMAGE_DIM, ) +from app.core.exceptions import AppException +from app.core.logger import logger +from app.deps.token_auth import MobileUserSchema, get_current_mobile_user from app.service.face_embedding import FaceImagePayload -from db.generated.models import User + + +pillow_heif.register_heif_opener() + + +Image.MAX_IMAGE_PIXELS = MAX_IMAGE_DIM * MAX_IMAGE_DIM + + +class EnrollmentResponse(BaseModel): + id: uuid.UUID + + model_config = {"from_attributes": True} + router = APIRouter() -@router.post("/enroll") +def _sanitise_filename(raw: str | None, extension: str) -> str: + prefix = str(uuid.uuid4()) + if not raw: + return f"{prefix}.{extension}" + name = re.sub(r'[\\/:*?"<>|\x00-\x1f]', "_", raw) + name = name.replace("..", "_") + name = name.lstrip(".")[:128] + return f"{prefix}_{name}" + + +def _validate_dimensions(contents: bytes) -> None: + + try: + img = Image.open(BytesIO(contents)) + w, h = img.size + except Exception as e: + raise AppException.image_format_error( + "File could not be decoded as a valid image" + ) from e + + max_pixels = Image.MAX_IMAGE_PIXELS + + if max_pixels is not None and w * h > max_pixels: + raise AppException.bad_request( + f"Image exceeds maximum allowed resolution of {max_pixels} total pixels." + ) + + try: + img.load() + except Exception as e: + raise AppException.image_format_error( + "File contains corrupted or incomplete pixel data" + ) from e + + if w < MIN_IMAGE_DIM or h < MIN_IMAGE_DIM: + raise AppException.bad_request( + f"Image too small — minimum {MIN_IMAGE_DIM}x{MIN_IMAGE_DIM} px" + ) + if w > MAX_IMAGE_DIM or h > MAX_IMAGE_DIM: + raise AppException.bad_request( + f"Image too large — maximum {MAX_IMAGE_DIM}x{MAX_IMAGE_DIM} px" + ) + + +async def read_limited(file: UploadFile, limit: int) -> bytes: + chunks: list[bytes] = [] + total = 0 + while True: + chunk = await file.read(65536) + if not chunk: + break + total += len(chunk) + if total > limit: + raise AppException.bad_request( + f"File exceeds maximum allowed size of {limit} bytes" + ) + chunks.append(chunk) + + await file.seek(0) + return b"".join(chunks) + + +def _precheck_upload_headers(file: UploadFile) -> None: + content_type = file.content_type + if not content_type: + raise AppException.image_format_error("Missing image Content-Type header") + + normalized_content_type = content_type.split(";", maxsplit=1)[0].strip().lower() + if normalized_content_type not in IMAGE_ALLOWED_TYPES: + allowed = ", ".join(IMAGE_ALLOWED_TYPES) + raise AppException.image_format_error( + f"Unsupported Content-Type header. Allowed types: {allowed}" + ) + + content_length = file.headers.get("content-length") + if content_length is None: + return + + try: + declared_size = int(content_length) + except ValueError as exc: + raise AppException.bad_request("Invalid image Content-Length header") from exc + + if declared_size > MAX_IMAGE_SIZE: + raise AppException.bad_request( + f"File exceeds maximum allowed size of {MAX_IMAGE_SIZE} bytes" + ) + + +async def _build_face_image_payload(file: UploadFile) -> FaceImagePayload: + _precheck_upload_headers(file) + contents = await read_limited(file, MAX_IMAGE_SIZE) + + kind = filetype.guess(contents) + if kind is None or kind.mime not in IMAGE_ALLOWED_TYPES: + raise AppException.image_format_error( + f"Unsupported format. Allowed types: {', '.join(IMAGE_ALLOWED_TYPES)}" + ) + + await run_in_threadpool(_validate_dimensions, contents) + + return FaceImagePayload( + filename=_sanitise_filename(file.filename, kind.extension), + content_type=kind.mime, + bytes=contents, + ) + + +async def _record_enrollment_audit( + *, + container: Container, + user_id: uuid.UUID, + image_count: int, + outcome: str, + duration_ms: int, + error_category: str | None = None, +) -> None: + metadata: dict[str, object] = { + "endpoint": "enroll", + "image_count": image_count, + "outcome": outcome, + "duration_ms": duration_ms, + } + if error_category is not None: + metadata["error_category"] = error_category + + try: + await container.audit_service.create_record( + event_type=AuditEventType.FACE_ENROLLMENT_ATTEMPT, + user_id=user_id, + metadata=metadata, + ) + except Exception as exc: + logger.warning( + "Failed to publish enrollment audit for user %s: %s", user_id, exc + ) + + +def _enrollment_lock_key(user_id: uuid.UUID) -> str: + return f"enroll:in_progress:{user_id}" + + +async def _release_enrollment_lock( + *, + container: Container, + lock_key: str, + lock_value: str, +) -> None: + try: + if await container.redis.get(lock_key) == lock_value: + await container.redis.delete(lock_key) + except Exception as exc: + logger.warning("Failed to release enrollment lock %s: %s", lock_key, exc) + + +@router.post("/enroll", response_model=EnrollmentResponse) async def enroll_face( - files: Annotated[ + files: Annotated[ List[UploadFile], File( - description="Upload one or more face images", - openapi_examples={ - "single_file": { - "summary": "One file example", - "description": "Example of uploading one file", - "value": "example.jpg" - }, - "multiple_files": { - "summary": "Multiple files example", - "description": "Example of uploading multiple files", - "value": ["face1.png", "face2.png"] - }, - }, + description=( + f"Between {MIN_ENROLL_IMAGES} and {MAX_ENROLL_IMAGES} face images " + f"(JPEG, PNG, HEIC, or HEIF). " + f"Each file must be under {MAX_IMAGE_SIZE // (1024 * 1024)} MB " + f"and at least {MIN_IMAGE_DIM}x{MIN_IMAGE_DIM} px." + ), ), ], container: Container = Depends(get_container), user: MobileUserSchema = Depends(get_current_mobile_user), -) -> User: - - if not (MIN_ENROLL_IMAGES <= len(files) <= MAX_ENROLL_IMAGES): - raise AppException.bad_request( - f"You must upload between {MIN_ENROLL_IMAGES} and {MAX_ENROLL_IMAGES} images for enrollment." - ) +) -> EnrollmentResponse: + start_time = time.perf_counter() + image_count = len(files) + lock_key: str | None = None + lock_value: str | None = None + lock_acquired = False + async def image_payloads() -> AsyncIterator[FaceImagePayload]: + for file in files: + yield await _build_face_image_payload(file) - image_payloads: list[FaceImagePayload] = [] - for file in files: - if file.content_type not in IMAGE_ALLOWED_TYPES: - raise AppException.image_format_error( - f"File {file.filename} has unsupported format {file.content_type}" - ) + try: + await container.auth_service.check_rate_limit( + redis=container.redis, + key=f"rate:enroll:{user.user_id}", + max_requests=ENROLL_RATE_LIMIT_MAX, + window_seconds=ENROLL_RATE_LIMIT_WINDOW, + ) - contents = await file.read() - if len(contents) > MAX_IMAGE_SIZE: + if not (MIN_ENROLL_IMAGES <= image_count <= MAX_ENROLL_IMAGES): raise AppException.bad_request( - f"File {file.filename} exceeds maximum size of {MAX_IMAGE_SIZE} bytes" + f"You must upload between {MIN_ENROLL_IMAGES} and " + f"{MAX_ENROLL_IMAGES} images for enrollment." ) - payload: FaceImagePayload = FaceImagePayload( - filename=file.filename or "unknown", - content_type=file.content_type or DEFAULT_CONTENT_TYPE, - bytes=contents, + lock_key = _enrollment_lock_key(user.user_id) + lock_value = str(uuid.uuid4()) + lock_acquired = await container.redis.set( + lock_key, + lock_value, + expire=ENROLL_IN_PROGRESS_TTL_SECONDS, + nx=True, ) + if not lock_acquired: + raise AppException.conflict( + "Enrollment already in progress. Please wait for it to finish." + ) - image_payloads.append(payload) - - return await container.auth_service.add_embbed_user( - user.user_id, - image_payloads, - ) + updated_user = await container.auth_service.add_embbed_user( + user.user_id, + image_payloads(), + ) + await _record_enrollment_audit( + container=container, + user_id=user.user_id, + image_count=image_count, + outcome="success", + duration_ms=int((time.perf_counter() - start_time) * 1000), + ) + return EnrollmentResponse.model_validate(updated_user) + except HTTPException as exc: + await _record_enrollment_audit( + container=container, + user_id=user.user_id, + image_count=image_count, + outcome="failure", + duration_ms=int((time.perf_counter() - start_time) * 1000), + error_category=f"http_{exc.status_code}", + ) + raise + except Exception as e: + await _record_enrollment_audit( + container=container, + user_id=user.user_id, + image_count=image_count, + outcome="failure", + duration_ms=int((time.perf_counter() - start_time) * 1000), + error_category="unexpected_error", + ) + raise AppException.internal_error( + "Enrollment failed due to an internal error" + ) from e + finally: + if lock_acquired and lock_key is not None and lock_value is not None: + await _release_enrollment_lock( + container=container, + lock_key=lock_key, + lock_value=lock_value, + ) diff --git a/app/router/mobile/photos.py b/app/router/mobile/photos.py index 01bdfe63..00113f97 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/router/staff/__init__.py b/app/router/staff/__init__.py index e17b02c2..35cbe0ec 100644 --- a/app/router/staff/__init__.py +++ b/app/router/staff/__init__.py @@ -4,7 +4,7 @@ from app.router.staff.notifications import router as staff_notifications_router from app.router.staff.uploads import router as staff_uploads_router -router = APIRouter(prefix="/stuff", tags=["stuff"]) +router = APIRouter(prefix="/staff", tags=["staff"]) router.include_router(staff_drive_router) router.include_router(staff_notifications_router) router.include_router(staff_uploads_router) diff --git a/app/router/web/auth.py b/app/router/web/auth.py index 0eecad77..7629cf93 100644 --- a/app/router/web/auth.py +++ b/app/router/web/auth.py @@ -3,6 +3,7 @@ from fastapi import Response from app.deps.cookie_auth import get_current_staff_user +from app.deps.rate_limit import RateLimiter from app.schema.request.web.auth import WebAuthRequest from app.schema.response.web.auth import WebAuthResponse from app.schema.response.web.staff_user import StaffUserSchema @@ -10,7 +11,7 @@ router = APIRouter(prefix="/auth") -@router.post("/login", response_model=WebAuthResponse,description="so here both the dahbsoard will authneticate from this endpoitn ") +@router.post("/login", response_model=WebAuthResponse, description="so here both the dahbsoard will authneticate from this endpoitn ", dependencies=[Depends(RateLimiter(requests=5, window=60))]) async def admin_login( req: WebAuthRequest, r:Response, diff --git a/app/schema/request/mobile/auth.py b/app/schema/request/mobile/auth.py index b272f2b7..23a13717 100644 --- a/app/schema/request/mobile/auth.py +++ b/app/schema/request/mobile/auth.py @@ -63,9 +63,24 @@ class MobileLoginRequest(MobileAuthBaseRequest): pass +class RegisterVerifyRequest(MobileAuthBaseRequest): + otp: str = Field(..., min_length=6, max_length=6, description="The 6-digit OTP code sent via email") + + +class ResendOtpRequest(BaseModel): + email: EmailStr = Field(..., max_length=255) + + @field_validator("email", mode="before") + @classmethod + def _normalize_email(cls, value: object) -> object: + if not isinstance(value, str): + return value + return value.strip().lower() + + class RefreshTokenRequest(BaseModel): refresh_token: str diff --git a/app/schema/response/mobile/auth.py b/app/schema/response/mobile/auth.py index 154f9a48..e03a0745 100644 --- a/app/schema/response/mobile/auth.py +++ b/app/schema/response/mobile/auth.py @@ -25,6 +25,10 @@ class MeResponse(BaseModel): sessions: Optional[SessionSchema] +class RegisterPendingResponse(BaseModel): + message: str + status: str + email: str class MobileAuthResponse(BaseModel): access_token: str diff --git a/app/service/face_embedding.py b/app/service/face_embedding.py index e5333a0d..a571f195 100644 --- a/app/service/face_embedding.py +++ b/app/service/face_embedding.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +from collections.abc import AsyncIterable, AsyncIterator from dataclasses import dataclass from typing import List, Literal, Optional, Sequence, Tuple, TypedDict @@ -134,20 +135,27 @@ async def compute_average_embedding( self, payloads: Sequence[FaceImagePayload], ) -> list[float]: + async def iter_payloads() -> AsyncIterator[FaceImagePayload]: + for payload in payloads: + yield payload - if not payloads: - raise AppException.bad_request( - "At least one image is required for enrollment" - ) + return await self.compute_average_embedding_stream(iter_payloads()) + + async def compute_average_embedding_stream( + self, + payloads: AsyncIterable[FaceImagePayload], + ) -> list[float]: + has_payload = False embeddings: list[np.ndarray] = [] - for payload in payloads: + async for payload in payloads: + has_payload = True image = self._decode_image(payload) image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) # Single detection pass — model.get() already returns embeddings - faces: list[FaceStub] = await asyncio.to_thread( # type: ignore + faces: list[FaceStub] = await asyncio.to_thread( # type: ignore self.face_embedding.model.get, image_rgb # type: ignore ) @@ -165,6 +173,11 @@ async def compute_average_embedding( embeddings.append(face.embedding.astype(np.float32)) + if not has_payload: + raise AppException.bad_request( + "At least one image is required for enrollment" + ) + stacked = np.stack(embeddings, axis=0) averaged = np.mean(stacked, axis=0) diff --git a/app/service/photo_approval.py b/app/service/photo_approval.py index dd3f68e3..f3eb809c 100644 --- a/app/service/photo_approval.py +++ b/app/service/photo_approval.py @@ -64,6 +64,14 @@ async def decide( await self._photo_querier.update_photo_status(id=photo_id, status="approved") return "approved" + async def expire_stale(self, timeout_days: int) -> int: + count = 0 + async for _ in self._approval_querier.expire_stale_approvals(timeout_days=timeout_days): + count += 1 + if count: + logger.info("Auto-expired %d stale pending photo(s)", count) + return count + async def _delete_photo_storage(self, photo_id: UUID) -> None: photo = await self._photo_querier.get_photo_by_id(id=photo_id) if photo is None: diff --git a/app/service/staff_drive.py b/app/service/staff_drive.py index fb83da59..9cb6ba95 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 @@ -236,44 +237,59 @@ async def import_images_from_drive( if not selected_files: return [] + if len(selected_files) > 1000: + raise AppException.bad_request( + "Cannot import more than 1000 files at once. Please select fewer files." + ) + 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 + + await bucket.put_bytes( + data=download.content, + object_name=object_name, + content_type=content_type, + filename=selected.name, + ) - results.append(DriveImportResult( + 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}", - )) + ) + + results: list[DriveImportResult] = [] + # Process in chunks of 50 to prevent creating too many asyncio Task objects in memory + chunk_size = 50 + for i in range(0, len(selected_files), chunk_size): + chunk = selected_files[i : i + chunk_size] + tasks = [process_file(selected) for selected in chunk] + chunk_results = await asyncio.gather(*tasks) + results.extend(chunk_results) return results diff --git a/app/service/staff_user.py b/app/service/staff_user.py index 62418184..3a589e45 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 a269ff17..f8181bc0 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/app/service/users.py b/app/service/users.py index 2237d8c8..a713b8af 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -1,5 +1,6 @@ from datetime import datetime, timedelta, timezone import uuid +from collections.abc import AsyncIterable from typing import Optional from sqlalchemy.exc import SQLAlchemyError @@ -21,8 +22,12 @@ MobileAuthBaseRequest, MobileLoginRequest, MobileRegisterRequest, + RegisterVerifyRequest, ) -from app.schema.response.mobile.auth import MobileAuthResponse +from app.schema.response.mobile.auth import MobileAuthResponse, RegisterPendingResponse +from app.infra.nats import NatsClient +import secrets +import json from db.generated import user as user_queries from db.generated import devices as device_queries from db.generated import session as session_queries @@ -130,7 +135,7 @@ async def mobile_register( redis: RedisClient, req: MobileRegisterRequest, client_ip: Optional[str] = None, - ) -> MobileAuthResponse: + ) -> RegisterPendingResponse: logger.info("mobile_register attempt") max_attempts = settings.RATE_LIMIT_LOGIN_MAX_ATTEMPTS window = settings.RATE_LIMIT_LOGIN_WINDOW_SECONDS @@ -153,16 +158,105 @@ async def mobile_register( if existing_user is not None: logger.warning("register attempt: email_already_in_use") raise AppException.conflict("Email already in use; please login instead") + hashed = hash_password(req.password) - logger.info("register attempt: creating_new_user") + otp = "".join(secrets.choice("0123456789") for _ in range(6)) + + pending_key = f"pending_user:{req.email}" + pending_data = { + "hashed_password": hashed, + } + + # Save in Redis for 10 minutes (600 seconds) + await redis.set(pending_key, json.dumps(pending_data), expire=600) + await redis.set(f"otp:{req.email}", otp, expire=600) + + # Send to NATS + await NatsClient.publish("email.send_otp", json.dumps({"email": req.email, "otp": otp}).encode("utf-8")) + + logger.info("register success, OTP sent") + return RegisterPendingResponse( + message="OTP sent to email", + status="pending_verification", + email=req.email + ) + + async def mobile_register_resend_otp( + self, + redis: RedisClient, + email: str, + client_ip: Optional[str] = None, + ) -> RegisterPendingResponse: + logger.info("resend_otp attempt for %s", email) + max_attempts = settings.RATE_LIMIT_LOGIN_MAX_ATTEMPTS + window = settings.RATE_LIMIT_LOGIN_WINDOW_SECONDS + + if client_ip: + await self.check_rate_limit( + redis, + f"rate:ip:{client_ip}", + max_attempts, + window, + ) + await self.check_rate_limit( + redis, + f"rate:email:{email}", + max_attempts, + window, + ) + + pending_key = f"pending_user:{email}" + raw_data = await redis.get(pending_key) + if not raw_data: + raise AppException.not_found("No pending registration found for this email") + + otp = "".join(secrets.choice("0123456789") for _ in range(6)) + + # Regenerate OTP with 10 mins TTL, without touching the pending_user TTL + await redis.set(f"otp:{email}", otp, expire=600) + + # Send to NATS + await NatsClient.publish("email.send_otp", json.dumps({"email": email, "otp": otp}).encode("utf-8")) + + logger.info("resend_otp success, new OTP sent to %s", email) + return RegisterPendingResponse( + message="New OTP sent to email", + status="pending_verification", + email=email + ) + + async def verify_mobile_register( + self, + redis: RedisClient, + req: RegisterVerifyRequest, + client_ip: Optional[str] = None, + ) -> MobileAuthResponse: + otp_key = f"otp:{req.email}" + stored_otp = await redis.get(otp_key) + + if not stored_otp or stored_otp != req.otp: + raise AppException.unauthorized("Invalid or expired OTP") + + pending_key = f"pending_user:{req.email}" + raw_data = await redis.get(pending_key) + if not raw_data: + raise AppException.unauthorized("Registration session expired") + + data = json.loads(raw_data) + try: - user = await self.user_querier.create_user(email=req.email, hashed_password=hashed) + user = await self.user_querier.create_user(email=req.email, hashed_password=data["hashed_password"]) if not user: raise AppException.internal_error("Failed to create user") except SQLAlchemyError as exc: logger.error("Failed to create user: %s", exc) raise DBException.handle(exc) - logger.info("register success user_id=%s", user.id) + + # Clean up redis + await redis.delete(otp_key) + await redis.delete(pending_key) + + logger.info("register verify success user_id=%s", user.id) return await self._create_mobile_session( redis=redis, user=user, @@ -216,7 +310,6 @@ async def _create_mobile_session( expiry = Get_expiry_time() logger.info("session_created session_id=%s user_id=%s", session.id, user_id) - # Populate Redis auth cache for fast-path validation await SessionService.cache_session_for_auth( redis=redis, session_id=session.id, @@ -286,14 +379,33 @@ async def logout( async def add_embbed_user( self, user_id: uuid.UUID, - image_payloads: list[FaceImagePayload], + image_payloads: AsyncIterable[FaceImagePayload], ) -> User: logger.info("Generating face embeddings for user %s", user_id) - averaging = await self.face_embedding_service.compute_average_embedding( + existing = await self.user_querier.get_user_by_id(id=user_id) + if not existing: + raise AppException.not_found("User not found") + if existing.face_embedding is not None: + raise AppException.conflict( + "User already has an active face enrollment. " + "Delete the existing enrollment before re-enrolling." + ) + + averaging = await self.face_embedding_service.compute_average_embedding_stream( image_payloads ) vector_literal = "[" + ", ".join(str(x) for x in averaging) + "]" + + locked_existing = await self.user_querier.get_user_by_id_for_update(id=user_id) + if not locked_existing: + raise AppException.not_found("User not found") + if locked_existing.face_embedding is not None: + raise AppException.conflict( + "User already has an active face enrollment. " + "Delete the existing enrollment before re-enrolling." + ) + user = await self.user_querier.set_user_embedding( dollar_1=vector_literal, id=user_id, @@ -410,7 +522,6 @@ async def delete_user(self, *, redis: RedisClient, user_id: uuid.UUID) -> User: session_key = constant.RedisKey.UserSessionByUser.value.format( user_id=user_id ) - # Best-effort: also invalidate the per-session MobileSessionCache. raw_session_id = await redis.get(session_key) if raw_session_id: try: @@ -431,15 +542,13 @@ async def block_user(self, *, redis: RedisClient, user_id: uuid.UUID) -> User: raise AppException.not_found("User not found") session_key = constant.RedisKey.UserSessionByUser.value.format(user_id=user_id) - # Best-effort: retrieve the session_id from UserSessionByUser cache to also - # invalidate the per-session MobileSessionCache entry. raw_session_id = await redis.get(session_key) if raw_session_id: try: session_id = uuid.UUID(raw_session_id) await SessionService.delete_session_cache(redis=redis, session_id=session_id) except (ValueError, Exception): - pass # non-blocking: session cache will expire naturally + pass await redis.delete(session_key) return user @@ -474,9 +583,9 @@ async def check_rate_limit( ) -> None: """Enforce rate limiting using Redis INCR + EXPIRE. - Increments a counter for ``key``. On the first increment the key + Increments a counter for ``key``. On the first increment the key is given a TTL of ``window_seconds`` so the window resets - automatically. If the counter exceeds ``max_requests`` a 429 + automatically. If the counter exceeds ``max_requests`` a 429 response is raised with a ``Retry-After`` header. """ current_count = await redis.incr(key) diff --git a/app/worker/email_worker/main.py b/app/worker/email_worker/main.py new file mode 100644 index 00000000..129d1aba --- /dev/null +++ b/app/worker/email_worker/main.py @@ -0,0 +1,62 @@ +import asyncio +import json + +from app.core.config import settings +from app.core.logger import logger +from app.infra.nats import NatsClient +from app.infra.email import EmailSender + + +async def handle_message(raw_payload: bytes | str) -> None: + try: + if isinstance(raw_payload, bytes): + raw_payload = raw_payload.decode() + + payload = json.loads(raw_payload) + email = payload.get("email") + otp = payload.get("otp") + + if not email or not otp: + logger.error("Invalid email.send_otp payload: %s", raw_payload) + return + + success = await EmailSender.send_otp_email(to_email=email, otp=otp) + if success: + logger.info("Successfully sent OTP email to %s", email) + else: + logger.error("Failed to send OTP email to %s", email) + + except Exception: + logger.exception("Unexpected error in email worker") + + +async def run_worker() -> None: + logger.info("Email worker started") + + async def wrapped_handler(msg: bytes | str) -> None: + await handle_message(msg) + + # Subscribe to the email.send_otp subject + await NatsClient.subscribe("email.send_otp", wrapped_handler) + + # Keep the worker running + await asyncio.Event().wait() + + +async def main() -> None: + await NatsClient.connect( + host=settings.NATS_HOST, + port=settings.NATS_PORT, + user=settings.NATS_USER, + password=settings.NATS_PASSWORD, + ) + + try: + await run_worker() + finally: + await NatsClient.close() + logger.info("Email Worker shutdown") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/app/worker/photo_worker/main.py b/app/worker/photo_worker/main.py index ba7e9600..59a116bb 100644 --- a/app/worker/photo_worker/main.py +++ b/app/worker/photo_worker/main.py @@ -126,6 +126,8 @@ async def _handle_single_face(self, event: PhotoProcessEvent, face: DetectedFace async def _handle_group_photo(self, event: PhotoProcessEvent, faces: list[DetectedFace]) -> None: logger.info("Processing group photo %s with %d faces", event.photo_id, len(faces)) + approvals_created = 0 + for face_index, face in enumerate(faces): bbox_json = json.dumps({ "x1": float(face.bbox[0]), @@ -151,6 +153,8 @@ async def _handle_group_photo(self, event: PhotoProcessEvent, faces: list[Detect logger.info("No match for face %d in photo %s", face_index, event.photo_id) continue + approvals_created += 1 + try: await self._notification_service.create_notification( user_id=approval.user_id, @@ -174,6 +178,9 @@ async def _handle_group_photo(self, event: PhotoProcessEvent, faces: list[Detect approval.user_id, event.photo_id, exc, ) + if approvals_created == 0: + logger.info("No users matched in group photo %s, leaving as pending", event.photo_id) + async def _create_job(self, event: PhotoProcessEvent) -> models.ProcessingJob | None: if self._pj_querier is None: diff --git a/db/generated/photo_approvals.py b/db/generated/photo_approvals.py index f7123928..d3776c39 100644 --- a/db/generated/photo_approvals.py +++ b/db/generated/photo_approvals.py @@ -11,6 +11,25 @@ from db.generated import models +EXPIRE_STALE_APPROVALS = """-- name: expire_stale_approvals \\:many +WITH stale_photos AS ( + SELECT id FROM photos + WHERE status = 'pending' + AND created_at < now() - make_interval(days => :p1::int) +), +_update_approvals AS ( + UPDATE photo_approvals + SET decision = 'approved', decided_at = now() + WHERE photo_id IN (SELECT id FROM stale_photos) + AND decision = 'pending' +) +UPDATE photos +SET status = 'approved' +WHERE id IN (SELECT id FROM stale_photos) +RETURNING id +""" + + CREATE_PHOTO_APPROVAL = """-- name: create_photo_approval \\:one INSERT INTO photo_approvals ( photo_id, @@ -49,6 +68,11 @@ class AsyncQuerier: def __init__(self, conn: sqlalchemy.ext.asyncio.AsyncConnection): self._conn = conn + async def expire_stale_approvals(self, *, timeout_days: int) -> AsyncIterator[uuid.UUID]: + result = await self._conn.stream(sqlalchemy.text(EXPIRE_STALE_APPROVALS), {"p1": timeout_days}) + async for row in result: + yield row[0] + async def create_photo_approval(self, *, photo_id: uuid.UUID, user_id: uuid.UUID, decision: str) -> Optional[models.PhotoApproval]: row = (await self._conn.execute(sqlalchemy.text(CREATE_PHOTO_APPROVAL), {"p1": photo_id, "p2": user_id, "p3": decision})).first() if row is None: diff --git a/db/generated/photo_faces.py b/db/generated/photo_faces.py index 507e6c6a..6578ef8d 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/generated/user.py b/db/generated/user.py index b2368579..674d4b4a 100644 --- a/db/generated/user.py +++ b/db/generated/user.py @@ -55,6 +55,14 @@ class FindClosestUserByEmbeddingRow: """ +GET_USER_BY_ID_FOR_UPDATE = """-- name: get_user_by_id_for_update \\:one +SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked +FROM users +WHERE id = :p1 +FOR UPDATE +""" + + LIST_USERS = """-- name: list_users \\:many SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked FROM users @@ -179,6 +187,22 @@ async def get_user_by_id(self, *, id: uuid.UUID) -> Optional[models.User]: blocked=row[8], ) + async def get_user_by_id_for_update(self, *, id: uuid.UUID) -> Optional[models.User]: + row = (await self._conn.execute(sqlalchemy.text(GET_USER_BY_ID_FOR_UPDATE), {"p1": id})).first() + if row is None: + return None + return models.User( + id=row[0], + email=row[1], + hashed_password=row[2], + created_at=row[3], + updated_at=row[4], + display_name=row[5], + face_embedding=row[6], + deleted_at=row[7], + blocked=row[8], + ) + async def list_users(self, *, limit: int, offset: int) -> AsyncIterator[models.User]: result = await self._conn.stream(sqlalchemy.text(LIST_USERS), {"p1": limit, "p2": offset}) async for row in result: diff --git a/db/queries/photo_approvals.sql b/db/queries/photo_approvals.sql index ac6b787d..298f1fce 100644 --- a/db/queries/photo_approvals.sql +++ b/db/queries/photo_approvals.sql @@ -17,6 +17,23 @@ RETURNING *; -- name: GetPhotoApprovalsByPhotoId :many SELECT * FROM photo_approvals WHERE photo_id = $1; +-- name: ExpireStaleApprovals :many +WITH stale_photos AS ( + SELECT id FROM photos + WHERE status = 'pending' + AND created_at < now() - make_interval(days => $1::int) +), +_update_approvals AS ( + UPDATE photo_approvals + SET decision = 'approved', decided_at = now() + WHERE photo_id IN (SELECT id FROM stale_photos) + AND decision = 'pending' +) +UPDATE photos +SET status = 'approved' +WHERE id IN (SELECT id FROM stale_photos) +RETURNING id; + -- name: ListApprovalsByUserAndStatus :many SELECT * FROM photo_approvals WHERE user_id = $1 diff --git a/db/queries/photo_faces.sql b/db/queries/photo_faces.sql index a6286d9a..5fab5ce1 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/db/queries/user.sql b/db/queries/user.sql index a46577be..fc906dd8 100644 --- a/db/queries/user.sql +++ b/db/queries/user.sql @@ -8,6 +8,12 @@ SELECT * FROM users WHERE id = $1; +-- name: GetUserByIdForUpdate :one +SELECT * +FROM users +WHERE id = $1 +FOR UPDATE; + -- name: GetUserByEmail :one SELECT * FROM users diff --git a/docker-compose.staging.local.yml b/docker-compose.staging.local.yml index 135eb75b..0d3c3781 100644 --- a/docker-compose.staging.local.yml +++ b/docker-compose.staging.local.yml @@ -14,5 +14,13 @@ services: dockerfile: Dockerfile pull_policy: never + email-worker: + build: + context: . + dockerfile: Dockerfile + pull_policy: never + volumes: + - ./:/app + volumes: insightface_cache: diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index be3ff4ce..93435d84 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -96,6 +96,19 @@ services: networks: - multi_network + email-worker: + image: ghcr.io/microclub-usthb/multai-back:latest + container_name: multi_email_worker + restart: unless-stopped + env_file: + - .env.staging + depends_on: + - nats + - redis + command: ["uv", "run", "python", "-m", "app.worker.email_worker.main"] + networks: + - multi_network + volumes: postgres_data: minio_data: diff --git a/pyproject.toml b/pyproject.toml index cb5cd014..67db89d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,9 @@ dependencies = [ "firebase-admin>=6.8.0", "pywebpush>=2.3.0", "opencv-python>=4.13.0.92", + "filetype>=1.2.0", + "pillow-heif>=1.3.0", + "sqlalchemy>=2.0.47", ] [tool.ruff] @@ -60,7 +63,9 @@ warn_redundant_casts = true follow_imports = "silent" files = ["app", "db", "migrations"] exclude = [ - "db/generated" + "db/generated", + "tests", + "scripts" ] [dependency-groups] dev = [ diff --git a/scripts/check_ai_results.py b/scripts/check_ai_results.py new file mode 100644 index 00000000..fa179436 --- /dev/null +++ b/scripts/check_ai_results.py @@ -0,0 +1,23 @@ +import asyncio +from sqlalchemy.ext.asyncio import create_async_engine +from app.core.config import settings + +async def main(): + url = f"postgresql+asyncpg://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@{settings.POSTGRES_HOST}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}" + engine = create_async_engine(url) + + async with engine.connect() as conn: + import sqlalchemy + + # Check processing jobs + jobs = (await conn.execute(sqlalchemy.text("SELECT status, count(*) FROM processing_jobs GROUP BY status"))).fetchall() + print("=== PROCESSING JOBS STATUS ===") + for j in jobs: + print(f"Status: {j[0]}, Count: {j[1]}") + + # Check photo faces + faces = (await conn.execute(sqlalchemy.text("SELECT count(*) FROM photo_faces"))).scalar() + print("\n=== VISAGES DETECTES ===") + print(f"Nombre total de visages isolés et enregistrés par l'IA : {faces}") + +asyncio.run(main()) diff --git a/scripts/check_scopes.py b/scripts/check_scopes.py new file mode 100644 index 00000000..1835786e --- /dev/null +++ b/scripts/check_scopes.py @@ -0,0 +1,17 @@ +import asyncio +from sqlalchemy.ext.asyncio import create_async_engine +from app.core.config import settings + +async def main(): + url = f"postgresql+asyncpg://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@{settings.POSTGRES_HOST}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}" + engine = create_async_engine(url) + + async with engine.connect() as conn: + import sqlalchemy + row = (await conn.execute(sqlalchemy.text("SELECT google_email, scopes FROM staff_drive_connections LIMIT 1"))).fetchone() + if row: + print(f"Email: {row[0]}, Scopes: {row[1]}") + else: + print("No connection found") + +asyncio.run(main()) diff --git a/scripts/generate_drive_url.py b/scripts/generate_drive_url.py new file mode 100644 index 00000000..91a58023 --- /dev/null +++ b/scripts/generate_drive_url.py @@ -0,0 +1,44 @@ +import asyncio +from sqlalchemy.ext.asyncio import create_async_engine +from app.core.config import settings +from db.generated import stuff_user as staff_queries +from app.infra.redis import RedisClient +from app.service.staff_drive import StaffDriveService +from db.generated import staff_drive_connections as drive_queries + +async def main(): + url = f"postgresql+asyncpg://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@{settings.POSTGRES_HOST}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}" + engine = create_async_engine(url) + + # Init redis + RedisClient.init(host=settings.REDIS_HOST, port=settings.REDIS_PORT, password=settings.REDIS_PASSWORD or "") + redis = RedisClient.get_instance() + + async with engine.connect() as conn: + q = staff_queries.AsyncQuerier(conn) + import sqlalchemy + row = (await conn.execute(sqlalchemy.text("SELECT id FROM staff_users LIMIT 1"))).fetchone() + + if not row: + print("No staff user found! Creating one...") + res = await q.create_staff_user(email="testadmin@example.com", hashed_password="pw", display_name="Admin", role="admin") + staff_user_id = res.id + else: + staff_user_id = row[0] + + class DummyUser: + pass + staff_user = DummyUser() + staff_user.id = staff_user_id + + drive_service = StaffDriveService( + staff_user_querier=q, + drive_connection_querier=drive_queries.AsyncQuerier(conn), + redis=redis + ) + url, state = await drive_service.create_connect_url(staff_user) + print("========================") + print("GOOGLE_AUTH_URL:", url) + print("========================") + +asyncio.run(main()) diff --git a/scripts/list_drive.py b/scripts/list_drive.py new file mode 100644 index 00000000..d4940bfc --- /dev/null +++ b/scripts/list_drive.py @@ -0,0 +1,50 @@ +import asyncio +from sqlalchemy.ext.asyncio import create_async_engine +from app.core.config import settings +from db.generated import stuff_user as staff_queries +from app.infra.redis import RedisClient +from app.infra.google_drive import GoogleDriveClient +from app.service.staff_drive import StaffDriveService +from db.generated import staff_drive_connections as drive_queries + +async def main(): + url = f"postgresql+asyncpg://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@{settings.POSTGRES_HOST}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}" + engine = create_async_engine(url) + + # Init redis + try: + RedisClient.init(host=settings.REDIS_HOST, port=settings.REDIS_PORT, password=settings.REDIS_PASSWORD or "") + except RuntimeError: + pass + redis = RedisClient.get_instance() + + async with engine.connect() as conn: + q = staff_queries.AsyncQuerier(conn) + import sqlalchemy + row = (await conn.execute(sqlalchemy.text("SELECT id FROM staff_users LIMIT 1"))).fetchone() + staff_user_id = row[0] + + class DummyUser: + pass + staff_user = DummyUser() + staff_user.id = staff_user_id + + drive_service = StaffDriveService( + staff_user_querier=q, + drive_connection_querier=drive_queries.AsyncQuerier(conn), + redis=redis + ) + + access_token = await drive_service.get_access_token_for_staff_user(staff_user_id) + + # List root folders + print("=== DOSSIERS ET FICHIERS A LA RACINE DU DRIVE ===") + items = await GoogleDriveClient.list_folder_contents(access_token=access_token) + for i in items[:20]: + type_str = "📁 DOSSIER" if i.mime_type == "application/vnd.google-apps.folder" else "📄 FICHIER" + print(f"{type_str} | ID: {i.id} | NOM: {i.name}") + + if not items: + print("Aucun fichier trouvé.") + +asyncio.run(main()) diff --git a/scripts/test_routes.py b/scripts/test_routes.py new file mode 100644 index 00000000..f793df6f --- /dev/null +++ b/scripts/test_routes.py @@ -0,0 +1,8 @@ +from fastapi.testclient import TestClient +from app.main import app + +with TestClient(app) as client: + resp = client.get("/user/photos") + print(f"/user/photos -> {resp.status_code} {resp.text}") + resp2 = client.get("/user/photos/") + print(f"/user/photos/ -> {resp2.status_code} {resp2.text}") diff --git a/scripts/trigger_import.py b/scripts/trigger_import.py new file mode 100644 index 00000000..f3098789 --- /dev/null +++ b/scripts/trigger_import.py @@ -0,0 +1,73 @@ +import asyncio +from sqlalchemy.ext.asyncio import create_async_engine +from app.core.config import settings +from db.generated import stuff_user as staff_queries +from app.infra.redis import RedisClient +from app.infra.google_drive import GoogleDriveClient +from app.infra.minio import init_minio_client +from app.service.staff_drive import StaffDriveService, SelectedDriveFile +from db.generated import staff_drive_connections as drive_queries + +async def main(): + url = f"postgresql+asyncpg://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@{settings.POSTGRES_HOST}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}" + engine = create_async_engine(url) + + try: + RedisClient.init(host=settings.REDIS_HOST, port=settings.REDIS_PORT, password=settings.REDIS_PASSWORD or "") + except RuntimeError: + pass + redis = RedisClient.get_instance() + + # Init minio + await init_minio_client( + minio_host=settings.MINIO_HOST, + minio_port=settings.MINIO_API_PORT, + minio_root_user=settings.MINIO_ROOT_USER, + minio_root_password=settings.MINIO_ROOT_PASSWORD + ) + + async with engine.connect() as conn: + q = staff_queries.AsyncQuerier(conn) + import sqlalchemy + row = (await conn.execute(sqlalchemy.text("SELECT id FROM staff_users LIMIT 1"))).fetchone() + staff_user_id = row[0] + + class DummyUser: + pass + staff_user = DummyUser() + staff_user.id = staff_user_id + + drive_service = StaffDriveService( + staff_user_querier=q, + drive_connection_querier=drive_queries.AsyncQuerier(conn), + redis=redis + ) + + access_token = await drive_service.get_access_token_for_staff_user(staff_user_id) + + print("Fetching images from Drive...") + items = await GoogleDriveClient.list_folder_contents(access_token=access_token) + + # Filter images + image_items = [i for i in items if i.mime_type.startswith("image/")] + + if not image_items: + print("No images found to import.") + return + + print(f"Found {len(image_items)} images. Initiating import...") + selections = [ + SelectedDriveFile(id=f.id, name=f.name, mime_type=f.mime_type) + for f in image_items[:20] # Let's import up to 20 for this test + ] + + results = await drive_service.import_images_from_drive( + staff_user=staff_user, + selected_files=selections, + ) + + print(f"Successfully imported {len(results)} images to MinIO!") + for r in results: + print(f" -> {r.original_file_name} stored as {r.minio_object_name}") + +asyncio.run(main()) diff --git a/scripts/trigger_photo_worker.py b/scripts/trigger_photo_worker.py new file mode 100644 index 00000000..b1eba585 --- /dev/null +++ b/scripts/trigger_photo_worker.py @@ -0,0 +1,80 @@ +import asyncio +import json +import uuid +from sqlalchemy.ext.asyncio import create_async_engine +from app.core.config import settings +from app.infra.redis import RedisClient +from app.infra.minio import init_minio_client +from app.infra.nats import NatsClient, NatsSubjects + +async def main(): + url = f"postgresql+asyncpg://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@{settings.POSTGRES_HOST}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}" + engine = create_async_engine(url) + + try: + RedisClient.init(host=settings.REDIS_HOST, port=settings.REDIS_PORT, password=settings.REDIS_PASSWORD or "") + except RuntimeError: + pass + + await init_minio_client( + minio_host=settings.MINIO_HOST, + minio_port=settings.MINIO_API_PORT, + minio_root_user=settings.MINIO_ROOT_USER, + minio_root_password=settings.MINIO_ROOT_PASSWORD + ) + + await NatsClient.connect( + host=settings.NATS_HOST, + port=settings.NATS_PORT, + user=settings.NATS_USER, + password=settings.NATS_PASSWORD + ) + + async with engine.connect() as conn: + # staff_queries not used here but kept for context + import sqlalchemy + row = (await conn.execute(sqlalchemy.text("SELECT id FROM staff_users LIMIT 1"))).fetchone() + staff_user_id = row[0] + + event_row = (await conn.execute(sqlalchemy.text("SELECT id FROM events LIMIT 1"))).fetchone() + if not event_row: + print("Creating dummy event...") + ev_id = uuid.uuid4() + await conn.execute(sqlalchemy.text("INSERT INTO events (id, title, date, location) VALUES (:id, 'Test Event', now(), 'Test Location')"), {"id": ev_id}) + event_id = ev_id + else: + event_id = event_row[0] + + from app.infra.minio import ImageBucket + bucket = ImageBucket(f"staff-drive/{staff_user_id}") + + objects = bucket.client.list_objects(bucket.bucket_name, prefix=bucket.file_prefix + "/", recursive=True) + count = 0 + async for obj in objects: + storage_key = obj.object_name + print(f"Injecting photo {storage_key} into AI pipeline...") + + # Create photo in DB + new_id = uuid.uuid4() + await conn.execute( + sqlalchemy.text("INSERT INTO photos (id, event_id, storage_key, visibility) VALUES (:id, :event_id, :storage_key, 'public')"), + {"id": new_id, "event_id": event_id, "storage_key": storage_key} + ) + + # Publish event + await NatsClient.publish( + NatsSubjects.PHOTO_PROCESS, + json.dumps({ + "photo_id": str(new_id), + "image_ref": storage_key, + "event_id": str(event_id) + }).encode("utf-8") + ) + count += 1 + if count >= 20: + break + + await conn.commit() + print(f"Successfully injected {count} photos to the AI worker!") + +asyncio.run(main()) diff --git a/scripts/trigger_upload_request.py b/scripts/trigger_upload_request.py new file mode 100644 index 00000000..639579e1 --- /dev/null +++ b/scripts/trigger_upload_request.py @@ -0,0 +1,117 @@ +import asyncio +from sqlalchemy.ext.asyncio import create_async_engine +from app.core.config import settings +from db.generated import stuff_user as staff_queries +from db.generated import upload_request_groups as group_queries +from db.generated import upload_requests as request_queries +from db.generated import upload_request_photos as request_photo_queries +from db.generated import photos as photo_queries +from db.generated import staff_drive_connections as drive_queries +from db.generated import staff_notifications as notif_queries +from db.generated import audit as audit_queries +from app.service.upload_requests import UploadRequestsService +from app.service.staged_upload_storage import StagedUploadStorageService +from app.service.staff_drive import StaffDriveService +from app.service.staff_notifications import StaffNotificationsService +from app.service.audit import AuditService +from app.schema.request.staff.uploads import CreateUploadRequestPhotoRequest +from app.infra.redis import RedisClient +from app.infra.minio import init_minio_client +from app.infra.nats import NatsClient +import uuid + +async def main(): + url = f"postgresql+asyncpg://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@{settings.POSTGRES_HOST}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}" + engine = create_async_engine(url) + + try: + RedisClient.init(host=settings.REDIS_HOST, port=settings.REDIS_PORT, password=settings.REDIS_PASSWORD or "") + except RuntimeError: + pass + redis = RedisClient.get_instance() + + await init_minio_client( + minio_host=settings.MINIO_HOST, + minio_port=settings.MINIO_API_PORT, + minio_root_user=settings.MINIO_ROOT_USER, + minio_root_password=settings.MINIO_ROOT_PASSWORD + ) + + await NatsClient.connect( + host=settings.NATS_HOST, + port=settings.NATS_PORT, + user=settings.NATS_USER, + password=settings.NATS_PASSWORD + ) + + async with engine.connect() as conn: + q = staff_queries.AsyncQuerier(conn) + import sqlalchemy + row = (await conn.execute(sqlalchemy.text("SELECT id FROM staff_users LIMIT 1"))).fetchone() + staff_user_id = row[0] + + class DummyUser: + pass + staff_user = DummyUser() + staff_user.id = staff_user_id + staff_user.role = "multi_team_lead" # Important for approval! + + # Need an event + event_row = (await conn.execute(sqlalchemy.text("SELECT id FROM events LIMIT 1"))).fetchone() + if not event_row: + print("Creating dummy event...") + ev_id = uuid.uuid4() + await conn.execute(sqlalchemy.text("INSERT INTO events (id, title, date, location) VALUES (:id, 'Test Event', now(), 'Test Location')"), {"id": ev_id}) + event_id = ev_id + else: + event_id = event_row[0] + + upload_service = UploadRequestsService( + upload_request_group_querier=group_queries.AsyncQuerier(conn), + upload_request_querier=request_queries.AsyncQuerier(conn), + upload_request_photo_querier=request_photo_queries.AsyncQuerier(conn), + photo_querier=photo_queries.AsyncQuerier(conn), + staged_upload_storage=StagedUploadStorageService(), + staff_drive_service=StaffDriveService(staff_user_querier=q, drive_connection_querier=drive_queries.AsyncQuerier(conn), redis=redis), + staff_notifications_service=StaffNotificationsService(notif_queries.AsyncQuerier(conn)), + audit_service=AuditService(audit_queries.AsyncQuerier(conn), None), + ) + + # Get staged photos from minio bucket for this staff user + from app.infra.minio import ImageBucket + bucket = ImageBucket(f"staff-drive/{staff_user_id}") + + objects = bucket.client.list_objects(bucket.bucket_name, prefix=bucket.file_prefix + "/", recursive=True) + photo_inputs = [] + async for obj in objects: + name = obj.object_name.split("/")[-1] + photo_inputs.append(CreateUploadRequestPhotoRequest( + staged_object_name=name, + original_file_name=name, + )) + if len(photo_inputs) >= 20: + break + + if not photo_inputs: + print("No staged photos found in minio.") + return + + print(f"Submitting {len(photo_inputs)} photos to upload request...") + + req_details = await upload_service.create_upload( + event_id=event_id, + folder_id="drive-import", + photos=photo_inputs, + visibility="public", + day_number=1, + requested_by=staff_user, + ) + + print(f"Created upload request! ID: {req_details.id}") + print("Approving upload request to trigger AI pipeline...") + + await upload_service.approve_request(request_id=req_details.id, approved_by=staff_user) + + print("Done! Photos should now be processed by AI.") + +asyncio.run(main()) diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 00000000..b783ba4d --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,119 @@ +import asyncio +import os +import uuid +from collections.abc import AsyncGenerator +from pathlib import Path + +import pytest +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncConnection + +from app.core.config import settings +from app.infra.database import engine +from app.infra.minio import init_minio_client +from app.infra.nats import NatsClient + +FIXTURE_DIR = Path(__file__).parent.parent / "fixtures" / "images" + +# ── guard: only run when explicitly requested ───────────────────────── +def pytest_configure(config: pytest.Config) -> None: + config.addinivalue_line("markers", "e2e: mark test as an end-to-end test") + +@pytest.fixture(autouse=True) +async def setup_infra() -> AsyncGenerator[None, None]: + if os.getenv("MULTAI_RUN_E2E") != "1": + pytest.skip("set MULTAI_RUN_E2E=1 to run live e2e tests") + + await init_minio_client( + minio_host=settings.MINIO_HOST, + minio_port=settings.MINIO_API_PORT, + minio_root_user=settings.MINIO_ROOT_USER, + minio_root_password=settings.MINIO_ROOT_PASSWORD, + ) + await NatsClient.connect() + yield + await NatsClient.close() + NatsClient._nc = None # type: ignore[attr-defined] + await engine.dispose() + + +# ── shared helpers ──────────────────────────────────────────────────── + +async def _seed_event_and_photo( + conn: AsyncConnection, + *, + photo_id: uuid.UUID, + storage_key: str, +) -> uuid.UUID: + """Insert a staff user, a new event, and a photo. Returns event_id.""" + event_id = uuid.uuid4() + await conn.execute( # type: ignore[union-attr] + text( + """ + INSERT INTO staff_users (id, email, password, role) + VALUES ('00000000-0000-0000-0000-000000000001'::uuid, + 'e2e@test.com', 'hash', 'admin') + ON CONFLICT (id) DO NOTHING + """ + ) + ) + event_code = f"E2E-{str(uuid.uuid4())[:6].upper()}" + await conn.execute( # type: ignore[union-attr] + text( + """ + INSERT INTO events (id, name, event_code, event_date, status, created_by) + VALUES (:id, 'E2E Test Event', :code, NOW(), 'draft', + '00000000-0000-0000-0000-000000000001'::uuid) + """ + ), + {"id": event_id, "code": event_code}, + ) + await conn.execute( # type: ignore[union-attr] + text( + """ + INSERT INTO photos (id, event_id, storage_key, visibility, status) + VALUES (:id, :event_id, :key, 'private', 'pending') + """ + ), + {"id": photo_id, "event_id": event_id, "key": storage_key}, + ) + return event_id + + +async def _wait_for_job(photo_id: uuid.UUID, timeout_s: int = 60) -> str: + """Poll processing_jobs until terminal status. Returns 'completed', 'failed', or 'timeout'.""" + deadline = asyncio.get_event_loop().time() + timeout_s + async with engine.connect() as conn: + while asyncio.get_event_loop().time() < deadline: + row = ( + await conn.execute( + text( + "SELECT status FROM processing_jobs " + "WHERE photo_id = :pid AND job_type = 'face_detection'" + ), + {"pid": photo_id}, + ) + ).fetchone() + if row and row[0] in ("completed", "failed"): + return str(row[0]) + await asyncio.sleep(1.0) + return "timeout" + + +async def _cleanup( + conn: AsyncConnection, + *, + photo_id: uuid.UUID, + event_id: uuid.UUID, + user_id: str | None = None, +) -> None: + """Delete all rows created during a test, in FK-safe order.""" + if user_id: + await conn.execute(text("DELETE FROM notifications WHERE user_id = :uid"), {"uid": user_id}) # type: ignore[union-attr] + await conn.execute(text("DELETE FROM face_matches WHERE user_id = :uid"), {"uid": user_id}) # type: ignore[union-attr] + await conn.execute(text("DELETE FROM users WHERE id = :uid"), {"uid": user_id}) # type: ignore[union-attr] + await conn.execute(text("DELETE FROM face_matches fm USING photo_faces pf WHERE pf.id = fm.photo_face_id AND pf.photo_id = :pid"), {"pid": photo_id}) # type: ignore[union-attr] + await conn.execute(text("DELETE FROM photo_faces WHERE photo_id = :pid"), {"pid": photo_id}) # type: ignore[union-attr] + await conn.execute(text("DELETE FROM processing_jobs WHERE photo_id = :pid"), {"pid": photo_id}) # type: ignore[union-attr] + await conn.execute(text("DELETE FROM photos WHERE id = :pid"), {"pid": photo_id}) # type: ignore[union-attr] + await conn.execute(text("DELETE FROM events WHERE id = :eid"), {"eid": event_id}) # type: ignore[union-attr] diff --git a/tests/e2e/test_mobile_auth_intent_e2e.py b/tests/e2e/test_mobile_auth_intent_e2e.py index 4d569784..9bdaa76f 100644 --- a/tests/e2e/test_mobile_auth_intent_e2e.py +++ b/tests/e2e/test_mobile_auth_intent_e2e.py @@ -58,16 +58,39 @@ def test_register_with_existing_email_fails(self) -> None: headers=self.headers, ) assert response1.status_code == 200 - assert response1.json()["is_new_user"] is True + assert response1.json()["status"] == "pending_verification" - # Second registration with same email fails + # Second registration with same email should also succeed and resend OTP + # Wait, if they are still pending, it just overwrites the pending data. + # But if they are FULLY registered, it returns 409. + # Let's verify them first to fully register them. + import redis + r = redis.Redis(host="localhost", port=6379, decode_responses=True) + otp = r.get(f"otp:{email}") + + verify_payload = { + "email": email, + "password": "ValidPass@123", + "otp": otp, + "device_name": "TestDevice", + "device_type": "android", + "device_id": device_id, + } + verify_response = requests.post( + f"{self.base_url}/user/auth/register/verify", + json=verify_payload, + headers=self.headers, + ) + assert verify_response.status_code == 200 + + # Now 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() + assert "already in use" in response2.json()["detail"].lower() def test_register_then_login_succeeds(self) -> None: """Test full flow: register then login.""" @@ -89,8 +112,28 @@ def test_register_then_login_succeeds(self) -> None: 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"] + assert register_response.json()["status"] == "pending_verification" + + import redis + r = redis.Redis(host="localhost", port=6379, decode_responses=True) + otp = r.get(f"otp:{email}") + + verify_payload = { + "email": email, + "password": password, + "otp": otp, + "device_name": "TestDevice", + "device_type": "android", + "device_id": device_id, + } + verify_response = requests.post( + f"{self.base_url}/user/auth/register/verify", + json=verify_payload, + headers=self.headers, + ) + assert verify_response.status_code == 200 + assert verify_response.json()["is_new_user"] is True + register_token = verify_response.json()["access_token"] # Login with same credentials login_payload = { @@ -134,6 +177,25 @@ def test_login_with_wrong_password_fails(self) -> None: ) assert register_response.status_code == 200 + import redis + r = redis.Redis(host="localhost", port=6379, decode_responses=True) + otp = r.get(f"otp:{email}") + + verify_payload = { + "email": email, + "password": password, + "otp": otp, + "device_name": "TestDevice", + "device_type": "android", + "device_id": device_id, + } + verify_response = requests.post( + f"{self.base_url}/user/auth/register/verify", + json=verify_payload, + headers=self.headers, + ) + assert verify_response.status_code == 200 + # Try to login with wrong password login_payload = { "email": email, diff --git a/tests/e2e/test_mobile_auth_request_validation_e2e.py b/tests/e2e/test_mobile_auth_request_validation_e2e.py index 79907c00..96c7f0e2 100644 --- a/tests/e2e/test_mobile_auth_request_validation_e2e.py +++ b/tests/e2e/test_mobile_auth_request_validation_e2e.py @@ -40,7 +40,8 @@ def test_live_register_login_rejects_empty_required_text_fields( payload = _valid_payload() payload[field] = value - response = httpx.post(url, json=payload, timeout=10.0) + headers = {"X-Forwarded-For": f"203.0.113.{uuid.uuid4().int % 250 + 1}"} + response = httpx.post(url, json=payload, headers=headers, timeout=10.0) assert response.status_code == 422 @@ -50,6 +51,7 @@ 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) + headers = {"X-Forwarded-For": f"203.0.113.{uuid.uuid4().int % 250 + 1}"} + response = httpx.post(url, json=payload, headers=headers, timeout=10.0) assert response.status_code == 422 diff --git a/tests/e2e/test_photo_ai_edge_cases.py b/tests/e2e/test_photo_ai_edge_cases.py new file mode 100644 index 00000000..ed3e0766 --- /dev/null +++ b/tests/e2e/test_photo_ai_edge_cases.py @@ -0,0 +1,212 @@ +import json +import uuid + +from sqlalchemy import text + +from app.infra.database import engine +from app.infra.minio import Bucket, IMAGES_BUCKET_NAME +from app.infra.nats import NatsClient, NatsSubjects + +from tests.e2e.conftest import _seed_event_and_photo, _wait_for_job, _cleanup, FIXTURE_DIR + +# ── tests ───────────────────────────────────────────────────────────── + + +async def test_photo_ai_pipeline_detects_0_faces() -> None: + """Photo with no faces → auto-approved, visibility set to public.""" + photo_id = uuid.uuid4() + storage_key = f"e2e_noface_{photo_id}.jpg" + image_path = FIXTURE_DIR / "noface.jpg" + assert image_path.exists(), f"Fixture not found: {image_path}" + + bucket = Bucket(IMAGES_BUCKET_NAME, "") + await bucket.put_bytes( + object_name=storage_key, + data=image_path.read_bytes(), + content_type="image/jpeg", + ) + async with engine.begin() as conn: + event_id = await _seed_event_and_photo( + conn, photo_id=photo_id, storage_key=storage_key + ) + + payload = {"photo_id": str(photo_id), "image_ref": storage_key, "event_id": str(event_id)} + await NatsClient.publish(NatsSubjects.PHOTO_PROCESS, json.dumps(payload).encode("utf-8")) + + try: + final_status = await _wait_for_job(photo_id) + assert final_status == "completed", f"Job ended with: {final_status}" + + async with engine.connect() as conn: + row = ( + await conn.execute( + text("SELECT status, visibility FROM photos WHERE id = :pid"), + {"pid": photo_id}, + ) + ).fetchone() + assert row is not None + assert row[0] == "approved", f"Expected 'approved', got {row[0]}" + assert row[1] == "public", f"Expected visibility 'public', got {row[1]}" + finally: + async with engine.begin() as conn: + await _cleanup(conn, photo_id=photo_id, event_id=event_id) + try: + await bucket.delete(storage_key) + except Exception: + pass + + +async def test_photo_ai_pipeline_detects_multiple_faces() -> None: + """Group photo (multiple faces) → status stays 'pending' awaiting approval.""" + photo_id = uuid.uuid4() + storage_key = f"e2e_group_{photo_id}.jpg" + image_path = FIXTURE_DIR / "group.jpg" + assert image_path.exists(), f"Fixture not found: {image_path}" + + bucket = Bucket(IMAGES_BUCKET_NAME, "") + await bucket.put_bytes( + object_name=storage_key, + data=image_path.read_bytes(), + content_type="image/jpeg", + ) + async with engine.begin() as conn: + event_id = await _seed_event_and_photo( + conn, photo_id=photo_id, storage_key=storage_key + ) + + payload = {"photo_id": str(photo_id), "image_ref": storage_key, "event_id": str(event_id)} + await NatsClient.publish(NatsSubjects.PHOTO_PROCESS, json.dumps(payload).encode("utf-8")) + + try: + final_status = await _wait_for_job(photo_id) + assert final_status == "completed", f"Job ended with: {final_status}" + + async with engine.connect() as conn: + photo_status = ( + await conn.execute( + text("SELECT status FROM photos WHERE id = :pid"), + {"pid": photo_id}, + ) + ).scalar() + # Group photo → pending because multiple unverified faces require human approval + assert photo_status == "pending", f"Expected 'pending', got {photo_status}" + finally: + async with engine.begin() as conn: + await _cleanup(conn, photo_id=photo_id, event_id=event_id) + try: + await bucket.delete(storage_key) + except Exception: + pass + + +async def test_photo_ai_pipeline_matched_user() -> None: + """Single face matching an enrolled user → 'approved' + notification created.""" + from app.service.face_embedding import FaceEmbeddingService, FaceImagePayload + + photo_id = uuid.uuid4() + storage_key = f"e2e_matched_{photo_id}.jpg" + image_path = FIXTURE_DIR / "face.jpg" + assert image_path.exists(), f"Fixture not found: {image_path}" + + image_bytes = image_path.read_bytes() + + # Pre-compute the embedding so we can plant a matching user + face_service = FaceEmbeddingService() + payload_face = FaceImagePayload( + filename="face.jpg", content_type="image/jpeg", bytes=image_bytes + ) + faces = await face_service.detect_faces(payload_face) + assert len(faces) == 1, f"Expected exactly 1 face in face.jpg, got {len(faces)}" + embedding_literal = "[" + ", ".join(str(x) for x in faces[0].embedding) + "]" + + matched_user_id = "00000000-0000-0000-0000-000000000002" + + bucket = Bucket(IMAGES_BUCKET_NAME, "") + await bucket.put_bytes( + object_name=storage_key, data=image_bytes, content_type="image/jpeg" + ) + + async with engine.begin() as conn: + event_id = await _seed_event_and_photo( + conn, photo_id=photo_id, storage_key=storage_key + ) + # Clean up any previous failed run artifacts for this user + await conn.execute( + text("DELETE FROM notifications WHERE user_id = :uid"), + {"uid": matched_user_id}, + ) + await conn.execute( + text("DELETE FROM face_matches WHERE user_id = :uid"), + {"uid": matched_user_id}, + ) + await conn.execute( + text("DELETE FROM users WHERE id = :uid"), {"uid": matched_user_id} + ) + # Insert a user with the exact same embedding + await conn.execute( + text( + """ + INSERT INTO users (id, email, hashed_password, face_embedding) + VALUES (:uid, 'matched@test.com', 'hash', :emb) + """ + ), + {"uid": matched_user_id, "emb": embedding_literal}, + ) + + payload = {"photo_id": str(photo_id), "image_ref": storage_key, "event_id": str(event_id)} + await NatsClient.publish(NatsSubjects.PHOTO_PROCESS, json.dumps(payload).encode("utf-8")) + + try: + final_status = await _wait_for_job(photo_id) + assert final_status == "completed", f"Job ended with: {final_status}" + + async with engine.connect() as conn: + photo_status = ( + await conn.execute( + text("SELECT status FROM photos WHERE id = :pid"), + {"pid": photo_id}, + ) + ).scalar() + assert photo_status == "approved", ( + f"Expected 'approved', got {photo_status}" + ) + + matched_db_user = ( + await conn.execute( + text( + """ + SELECT fm.user_id + FROM face_matches fm + JOIN photo_faces pf ON pf.id = fm.photo_face_id + WHERE pf.photo_id = :pid + """ + ), + {"pid": photo_id}, + ) + ).scalar() + assert str(matched_db_user) == matched_user_id, ( + f"Expected matched user {matched_user_id}, got {matched_db_user}" + ) + + notif_count = ( + await conn.execute( + text( + "SELECT count(*) FROM notifications " + "WHERE user_id = :uid AND type = 'face_match'" + ), + {"uid": matched_user_id}, + ) + ).scalar() + assert notif_count == 1, f"Expected 1 notification, got {notif_count}" + finally: + async with engine.begin() as conn: + await _cleanup( + conn, + photo_id=photo_id, + event_id=event_id, + user_id=matched_user_id, + ) + try: + await bucket.delete(storage_key) + except Exception: + pass diff --git a/tests/e2e/test_photo_ai_load.py b/tests/e2e/test_photo_ai_load.py new file mode 100644 index 00000000..c3ca9c1e --- /dev/null +++ b/tests/e2e/test_photo_ai_load.py @@ -0,0 +1,218 @@ +import asyncio +import json +import os +import random +import uuid +from collections.abc import AsyncGenerator +from pathlib import Path + +import pytest +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncConnection + +from app.core.config import settings +from app.infra.database import engine +from app.infra.minio import Bucket, IMAGES_BUCKET_NAME, init_minio_client +from app.infra.nats import NatsClient, NatsSubjects + +# ── guard: only run when explicitly requested ───────────────────────── +pytestmark = [ + pytest.mark.e2e, + pytest.mark.asyncio, + pytest.mark.skipif( + os.getenv("MULTAI_RUN_E2E") != "1", + reason="set MULTAI_RUN_E2E=1 to run live e2e tests", + ), +] + +FIXTURE_DIR = Path(__file__).parent.parent / "fixtures" / "images" + + +@pytest.fixture(scope="function") +async def setup_infra() -> AsyncGenerator[None, None]: + await init_minio_client( + minio_host=settings.MINIO_HOST, + minio_port=settings.MINIO_API_PORT, + minio_root_user=settings.MINIO_ROOT_USER, + minio_root_password=settings.MINIO_ROOT_PASSWORD, + ) + await NatsClient.connect() + yield + await NatsClient.close() + NatsClient._nc = None # type: ignore[attr-defined] + await engine.dispose() + + +async def _setup_event(conn: AsyncConnection) -> uuid.UUID: + event_id = uuid.uuid4() + await conn.execute( # type: ignore[union-attr] + text( + """ + INSERT INTO staff_users (id, email, password, role) + VALUES ('00000000-0000-0000-0000-000000000001'::uuid, + 'e2e_load@test.com', 'hash', 'admin') + ON CONFLICT (id) DO NOTHING + """ + ) + ) + event_code = f"LOAD-{str(uuid.uuid4())[:6].upper()}" + await conn.execute( # type: ignore[union-attr] + text( + """ + INSERT INTO events (id, name, event_code, event_date, status, created_by) + VALUES (:id, 'E2E Load Event', :code, NOW(), 'draft', + '00000000-0000-0000-0000-000000000001'::uuid) + """ + ), + {"id": event_id, "code": event_code}, + ) + return event_id + + +async def _wait_for_jobs( + photo_ids: list[uuid.UUID], timeout: int = 180 +) -> dict[str, int]: + """Return a status→count dict once all jobs have a terminal status.""" + deadline = asyncio.get_event_loop().time() + timeout + async with engine.connect() as conn: + while asyncio.get_event_loop().time() < deadline: + rows = ( + await conn.execute( + text( + """ + SELECT status, count(*) + FROM processing_jobs + WHERE photo_id = ANY(:ids) + AND job_type = 'face_detection' + GROUP BY status + """ + ), + {"ids": photo_ids}, + ) + ).fetchall() + status_counts: dict[str, int] = {row[0]: int(row[1]) for row in rows} + total = sum(status_counts.values()) + if total == len(photo_ids): + completed = status_counts.get("completed", 0) + failed = status_counts.get("failed", 0) + if completed + failed == len(photo_ids): + return status_counts + await asyncio.sleep(2.0) + return {"timeout": 1} + + +async def test_photo_ai_load_20_photos(setup_infra: None) -> None: # noqa: ARG001 + """Process 20 photos concurrently; expect 0 failures within 3 minutes.""" + image_files = ["face.jpg", "group.jpg", "noface.jpg"] + image_contents: dict[str, bytes] = {} + for img in image_files: + path = FIXTURE_DIR / img + assert path.exists(), f"Fixture not found: {path}" + image_contents[img] = path.read_bytes() + + num_photos = 20 + photo_tasks: list[dict[str, object]] = [] + event_id: uuid.UUID | None = None + + async with engine.begin() as conn: + event_id = await _setup_event(conn) + for _ in range(num_photos): + photo_id = uuid.uuid4() + selected_img = random.choice(image_files) + storage_key = f"load-test/{event_id}/{photo_id}.jpg" + photo_tasks.append( + {"photo_id": photo_id, "storage_key": storage_key, "content": image_contents[selected_img]} + ) + await conn.execute( + text( + """ + INSERT INTO photos (id, event_id, storage_key, visibility, status) + VALUES (:id, :event_id, :key, 'private', 'pending') + """ + ), + {"id": photo_id, "event_id": event_id, "key": storage_key}, + ) + + assert event_id is not None + bucket = Bucket(IMAGES_BUCKET_NAME, "") + + try: + # 1. Upload all photos to MinIO concurrently + await asyncio.gather( + *[ + bucket.put_bytes( + object_name=p["storage_key"], # type: ignore[arg-type] + data=p["content"], # type: ignore[arg-type] + content_type="image/jpeg", + ) + for p in photo_tasks + ] + ) + + # 2. Publish 20 NATS messages concurrently + await asyncio.gather( + *[ + NatsClient.publish( + NatsSubjects.PHOTO_PROCESS.value, + json.dumps( + { + "photo_id": str(p["photo_id"]), + "image_ref": p["storage_key"], + "event_id": str(event_id), + } + ).encode("utf-8"), + ) + for p in photo_tasks + ] + ) + + # 3. Wait for all jobs + photo_ids: list[uuid.UUID] = [ + p["photo_id"] for p in photo_tasks # type: ignore[misc] + ] + status_counts = await _wait_for_jobs(photo_ids, timeout=180) + + assert "timeout" not in status_counts, ( + "Load test timed out waiting for jobs to complete" + ) + failed = status_counts.get("failed", 0) + completed = status_counts.get("completed", 0) + assert failed == 0, f"Expected 0 failed jobs, got {failed}" + assert completed == num_photos, ( + f"Expected {num_photos} completed jobs, got {completed}" + ) + finally: + # Always clean up DB and MinIO regardless of test outcome + async with engine.begin() as conn: + photo_ids_list = [p["photo_id"] for p in photo_tasks] + if photo_ids_list: + await conn.execute( + text( + "DELETE FROM face_matches fm " + "USING photo_faces pf " + "WHERE pf.id = fm.photo_face_id " + "AND pf.photo_id = ANY(:ids)" + ), + {"ids": photo_ids_list}, + ) + await conn.execute( + text("DELETE FROM photo_faces WHERE photo_id = ANY(:ids)"), + {"ids": photo_ids_list}, + ) + await conn.execute( + text("DELETE FROM processing_jobs WHERE photo_id = ANY(:ids)"), + {"ids": photo_ids_list}, + ) + await conn.execute( + text("DELETE FROM photos WHERE event_id = :eid"), + {"eid": event_id}, + ) + await conn.execute( + text("DELETE FROM events WHERE id = :eid"), {"eid": event_id} + ) + # Clean up MinIO objects + for p in photo_tasks: + try: + await bucket.delete(p["storage_key"]) # type: ignore[arg-type] + except Exception: + pass diff --git a/tests/e2e/test_photo_ai_pipeline_e2e.py b/tests/e2e/test_photo_ai_pipeline_e2e.py new file mode 100644 index 00000000..0a7b3b5a --- /dev/null +++ b/tests/e2e/test_photo_ai_pipeline_e2e.py @@ -0,0 +1,106 @@ +import json +import uuid + +from sqlalchemy import text + +from app.infra.database import engine +from app.infra.minio import Bucket, IMAGES_BUCKET_NAME +from app.infra.nats import NatsClient, NatsSubjects + +from tests.e2e.conftest import _seed_event_and_photo, _wait_for_job, _cleanup, FIXTURE_DIR + + +async def test_photo_ai_pipeline_detects_single_face() -> None: + """Single face in photo with no enrolled users → auto-approved.""" + photo_id = uuid.uuid4() + storage_key = f"e2e_test_{photo_id}.jpg" + image_path = FIXTURE_DIR / "face.jpg" + + assert image_path.exists(), f"Test fixture not found: {image_path}" + + bucket = Bucket(IMAGES_BUCKET_NAME, "") + + # 1. Seed MinIO + DB + await bucket.put_bytes( + object_name=storage_key, + data=image_path.read_bytes(), + content_type="image/jpeg", + ) + async with engine.begin() as conn: + event_id = await _seed_event_and_photo( + conn, photo_id=photo_id, storage_key=storage_key + ) + + # 2. Trigger worker + payload = { + "photo_id": str(photo_id), + "image_ref": storage_key, + "event_id": str(event_id), + } + await NatsClient.publish( + NatsSubjects.PHOTO_PROCESS, json.dumps(payload).encode("utf-8") + ) + + # 3. Assertions + Cleanup — always run cleanup via try/finally + try: + final_status = await _wait_for_job(photo_id, timeout_s=60) + assert final_status == "completed", f"Processing job ended with: {final_status}" + + async with engine.connect() as conn: + photo_status = ( + await conn.execute( + text("SELECT status FROM photos WHERE id = :pid"), + {"pid": photo_id}, + ) + ).scalar() + assert photo_status == "approved", ( + f"Expected photo status 'approved', got {photo_status}" + ) + finally: + async with engine.begin() as conn: + await _cleanup(conn, photo_id=photo_id, event_id=event_id) + try: + await bucket.delete(storage_key) + except Exception: + pass + + +async def test_photo_ai_pipeline_corrupt_image() -> None: + """Corrupt image → processing job fails, photo status remains pending or marked as error.""" + photo_id = uuid.uuid4() + storage_key = f"e2e_corrupt_{photo_id}.jpg" + + bucket = Bucket(IMAGES_BUCKET_NAME, "") + + # 1. Seed MinIO with corrupt data + DB + await bucket.put_bytes( + object_name=storage_key, + data=b"this is not a valid image file", + content_type="image/jpeg", + ) + async with engine.begin() as conn: + event_id = await _seed_event_and_photo( + conn, photo_id=photo_id, storage_key=storage_key + ) + + # 2. Trigger worker + payload = { + "photo_id": str(photo_id), + "image_ref": storage_key, + "event_id": str(event_id), + } + await NatsClient.publish( + NatsSubjects.PHOTO_PROCESS, json.dumps(payload).encode("utf-8") + ) + + # 3. Assertions + Cleanup + try: + final_status = await _wait_for_job(photo_id, timeout_s=30) + assert final_status == "failed", f"Expected job to fail, but ended with: {final_status}" + finally: + async with engine.begin() as conn: + await _cleanup(conn, photo_id=photo_id, event_id=event_id) + try: + await bucket.delete(storage_key) + except Exception: + pass diff --git a/tests/fixtures/images/face.jpg b/tests/fixtures/images/face.jpg new file mode 100644 index 00000000..f746a3cb Binary files /dev/null and b/tests/fixtures/images/face.jpg differ diff --git a/tests/fixtures/images/group.jpg b/tests/fixtures/images/group.jpg new file mode 100644 index 00000000..7991f1a0 Binary files /dev/null and b/tests/fixtures/images/group.jpg differ diff --git a/tests/fixtures/images/noface.jpg b/tests/fixtures/images/noface.jpg new file mode 100644 index 00000000..4a58e542 Binary files /dev/null and b/tests/fixtures/images/noface.jpg differ diff --git a/tests/integration/test_enrollment_flow.py b/tests/integration/test_enrollment_flow.py new file mode 100644 index 00000000..e1356f32 --- /dev/null +++ b/tests/integration/test_enrollment_flow.py @@ -0,0 +1,103 @@ +""" +Integration tests for the Enrollment Flow. + +These tests use a real PostgreSQL database to verify that +the user's face embedding is correctly persisted in the database. +""" + +import uuid +from unittest.mock import AsyncMock, MagicMock + +import pytest +from sqlalchemy import text + +from app.service.users import AuthService +from app.service.face_embedding import FaceImagePayload +from db.generated import user as user_queries + + +# =========================================================================== +# Fixtures +# =========================================================================== + + +@pytest.fixture +def mock_face_embedding() -> AsyncMock: + from app.service.face_embedding import FaceEmbeddingService + svc = MagicMock(spec=FaceEmbeddingService) + # Return a dummy embedding of size 512 + svc.compute_average_embedding_stream = AsyncMock(return_value=[0.1] * 512) + return svc + + +@pytest.fixture +async def db_conn(): + from sqlalchemy.ext.asyncio import create_async_engine + from app.core.config import settings + url = f"postgresql+asyncpg://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@{settings.POSTGRES_HOST}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}" + engine = create_async_engine(url, pool_pre_ping=True) + async with engine.connect() as conn: + yield conn + await engine.dispose() + +@pytest.fixture +def auth_service(mock_face_embedding: AsyncMock, db_conn) -> AuthService: + from db.generated import session as session_queries + from db.generated import devices as device_queries + + return AuthService( + user_querier=user_queries.AsyncQuerier(db_conn), + session_querier=session_queries.AsyncQuerier(db_conn), + device_querier=device_queries.AsyncQuerier(db_conn), + face_embedding_service=mock_face_embedding, + ) + + +# =========================================================================== +# Tests +# =========================================================================== + +# Mark these as integration tests (require DB) +pytestmark = pytest.mark.integration + + +@pytest.mark.asyncio +async def test_enrollment_persists_embedding( + auth_service: AuthService, + mock_face_embedding: AsyncMock, + db_conn, +) -> None: + """Test the happy path: add_embbed_user updates the user's face_embedding.""" + # 1. Setup: Create a user without an embedding + user = await user_queries.AsyncQuerier(db_conn).create_user( + email=f"test-enroll-{uuid.uuid4()}@multai.com", + hashed_password="hash", + ) + assert user is not None + user_id = user.id + + # 2. Execute enrollment + payload = FaceImagePayload(image_bytes=b"fake-image", filename="face.jpg") + + try: + await auth_service.add_embbed_user( + user_id=user_id, + image_payloads=[payload], + ) + + # 3. Verify: The user should now have an embedding + updated_user = await user_queries.AsyncQuerier(db_conn).get_user_by_id(id=user_id) + assert updated_user is not None + assert updated_user.face_embedding is not None + assert "0.1" in str(updated_user.face_embedding) + + # Face embedding service should have been called + mock_face_embedding.compute_average_embedding_stream.assert_called_once() + + finally: + # Cleanup + await db_conn.rollback() + await db_conn.execute( + text(f"DELETE FROM users WHERE id = '{user_id}'") + ) + await db_conn.commit() diff --git a/tests/integration/test_photo_approval_flow.py b/tests/integration/test_photo_approval_flow.py new file mode 100644 index 00000000..2606df8b --- /dev/null +++ b/tests/integration/test_photo_approval_flow.py @@ -0,0 +1,227 @@ +""" +Integration tests for the Photo Approval Flow. + +These tests use a real PostgreSQL database but mock MinIO and NATS. +They verify the full lifecycle of a group photo requiring multi-user approval. +""" + +import uuid +import datetime +from unittest.mock import AsyncMock, MagicMock + +import pytest +from sqlalchemy import text + +from app.service.photo_approval import PhotoApprovalService +from db.generated import user as user_queries +from db.generated import stuff_user as staff_queries +from db.generated import events as event_queries +from db.generated import photos as photo_queries +from db.generated import photo_approvals as approval_queries + + +# =========================================================================== +# Fixtures +# =========================================================================== + + +@pytest.fixture +def mock_storage() -> AsyncMock: + from app.service.staged_upload_storage import StagedUploadStorageService + svc = MagicMock(spec=StagedUploadStorageService) + svc.delete_storage_key = AsyncMock() + return svc + + +@pytest.fixture +def mock_audit() -> AsyncMock: + from app.service.audit import AuditService + svc = MagicMock(spec=AuditService) + svc.create_record = AsyncMock() + return svc + + +@pytest.fixture +async def db_conn(): + from sqlalchemy.ext.asyncio import create_async_engine + from app.core.config import settings + url = f"postgresql+asyncpg://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@{settings.POSTGRES_HOST}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}" + engine = create_async_engine(url, pool_pre_ping=True) + async with engine.connect() as conn: + yield conn + await engine.dispose() + +@pytest.fixture +def approval_service( + mock_storage: AsyncMock, + mock_audit: AsyncMock, + db_conn, +) -> PhotoApprovalService: + return PhotoApprovalService( + photo_approval_querier=approval_queries.AsyncQuerier(db_conn), + photo_querier=photo_queries.AsyncQuerier(db_conn), + storage_service=mock_storage, + audit_service=mock_audit, + ) + + +# =========================================================================== +# Tests +# =========================================================================== + +# Mark these as integration tests (require DB) +pytestmark = pytest.mark.integration + + +@pytest.mark.asyncio +async def test_group_photo_approval_lifecycle( + approval_service: PhotoApprovalService, + mock_storage: AsyncMock, + mock_audit: AsyncMock, + db_conn, +) -> None: + """Test that a photo becomes 'approved' only when all pending approvals are approved.""" + event_id = uuid.uuid4() + photo_id = uuid.uuid4() + + sq = staff_queries.AsyncQuerier(db_conn) + uq = user_queries.AsyncQuerier(db_conn) + eq = event_queries.AsyncQuerier(db_conn) + pq = photo_queries.AsyncQuerier(db_conn) + aq = approval_queries.AsyncQuerier(db_conn) + + staff = await sq.create_admin(email=f"admin-{uuid.uuid4()}@test.com", password="hash") + event_creator_id = staff.id + + user_ids = [] + for i in range(3): + u = await uq.create_user(email=f"approval-{uuid.uuid4()}@test.com", hashed_password="hash") + user_ids.append(u.id) + + uploader_id, user1_id, user2_id = user_ids + + event = await eq.create_event( + event_queries.CreateEventParams( + name="Approval Test Event", + event_code=f"APP{str(event_id)[:4]}", + event_date=datetime.datetime.now(datetime.timezone.utc), + status="scheduled", + created_by=event_creator_id + ) + ) + event_id = event.id + + await pq.create_photo( + photo_queries.CreatePhotoParams( + event_id=event_id, + storage_key="test/group.jpg", + taken_at=None, + day_number=None, + visibility="public" + ) + ) + + # Set status to pending and id + await db_conn.execute( + text(f"UPDATE photos SET id = '{photo_id}', status = 'pending', uploaded_by = '{uploader_id}' WHERE storage_key = 'test/group.jpg'") + ) + + await aq.create_photo_approval(photo_id=photo_id, user_id=user1_id, decision="pending") + await aq.create_photo_approval(photo_id=photo_id, user_id=user2_id, decision="pending") + + try: + # 2. User 1 approves + result1 = await approval_service.decide(photo_id=photo_id, user_id=user1_id, decision="approved") + assert result1 == "pending", "Photo should remain pending because User 2 hasn't approved yet" + + photo = await photo_queries.AsyncQuerier(db_conn).get_photo_by_id(id=photo_id) + assert photo.status == "pending" + + # 3. User 2 approves + result2 = await approval_service.decide(photo_id=photo_id, user_id=user2_id, decision="approved") + assert result2 == "approved", "Photo should be approved since all users approved" + + photo = await photo_queries.AsyncQuerier(db_conn).get_photo_by_id(id=photo_id) + assert photo.status == "approved" + + finally: + # 4. Cleanup + await db_conn.execute(text(f"DELETE FROM photo_approvals WHERE photo_id = '{photo_id}'")) + await db_conn.execute(text(f"DELETE FROM photos WHERE id = '{photo_id}'")) + await db_conn.execute(text(f"DELETE FROM events WHERE id = '{event_id}'")) + await db_conn.execute(text(f"DELETE FROM users WHERE id IN ('{user1_id}', '{user2_id}')")) + await db_conn.execute(text(f"DELETE FROM staff_users WHERE id = '{event_creator_id}'")) + await db_conn.commit() + + +@pytest.mark.asyncio +async def test_group_photo_rejection_deletes_storage( + approval_service: PhotoApprovalService, + mock_storage: AsyncMock, + db_conn, +) -> None: + """Test that a single rejection sets the photo to 'rejected' and deletes from MinIO.""" + event_id = uuid.uuid4() + photo_id = uuid.uuid4() + + sq = staff_queries.AsyncQuerier(db_conn) + uq = user_queries.AsyncQuerier(db_conn) + eq = event_queries.AsyncQuerier(db_conn) + pq = photo_queries.AsyncQuerier(db_conn) + aq = approval_queries.AsyncQuerier(db_conn) + + staff = await sq.create_admin(email=f"admin-{uuid.uuid4()}@test.com", password="hash") + event_creator_id = staff.id + + user_ids = [] + for i in range(2): + u = await uq.create_user(email=f"reject-{uuid.uuid4()}@test.com", hashed_password="hash") + user_ids.append(u.id) + + uploader_id, user1_id = user_ids + + event = await eq.create_event( + event_queries.CreateEventParams( + name="Reject Test Event", + event_code=f"REJ{str(event_id)[:4]}", + event_date=datetime.datetime.now(datetime.timezone.utc), + status="scheduled", + created_by=event_creator_id + ) + ) + event_id = event.id + + await pq.create_photo( + photo_queries.CreatePhotoParams( + event_id=event_id, + storage_key="test/reject.jpg", + taken_at=None, + day_number=None, + visibility="public" + ) + ) + + await db_conn.execute( + text(f"UPDATE photos SET id = '{photo_id}', status = 'pending', uploaded_by = '{uploader_id}' WHERE storage_key = 'test/reject.jpg'") + ) + + await aq.create_photo_approval(photo_id=photo_id, user_id=user1_id, decision="pending") + + try: + # 2. User 1 rejects + result = await approval_service.decide(photo_id=photo_id, user_id=user1_id, decision="rejected") + + # 3. Verify + assert result == "rejected" + mock_storage.delete_storage_key.assert_called_once_with("test/reject.jpg") + + photo = await photo_queries.AsyncQuerier(db_conn).get_photo_by_id(id=photo_id) + assert photo.status == "rejected" + + finally: + await db_conn.execute(text(f"DELETE FROM photo_approvals WHERE photo_id = '{photo_id}'")) + await db_conn.execute(text(f"DELETE FROM photos WHERE id = '{photo_id}'")) + await db_conn.execute(text(f"DELETE FROM events WHERE id = '{event_id}'")) + await db_conn.execute(text(f"DELETE FROM users WHERE id IN ('{uploader_id}', '{user1_id}')")) + await db_conn.execute(text(f"DELETE FROM staff_users WHERE id = '{event_creator_id}'")) + await db_conn.commit() diff --git a/tests/security/test_auth_security.py b/tests/security/test_auth_security.py new file mode 100644 index 00000000..76a3a0bc --- /dev/null +++ b/tests/security/test_auth_security.py @@ -0,0 +1,137 @@ +import uuid +import jwt +from datetime import datetime, timedelta, timezone + +import pytest +from httpx import AsyncClient, ASGITransport +from sqlalchemy import text + +from app.main import app +from app.core.config import settings +from db.generated import user as user_queries +from app.infra.database import engine +from app.infra.redis import RedisClient + +pytestmark = pytest.mark.asyncio(loop_scope="session") + +@pytest.fixture(scope="session", autouse=True) +async def setup_infra(): + # We must init Redis since ASGITransport doesn't trigger the lifespan + try: + RedisClient.init( + host=settings.REDIS_HOST, + port=settings.REDIS_PORT, + password=settings.REDIS_PASSWORD, + ) + except RuntimeError: + pass # Already initialized + yield + await RedisClient.get_instance().close() + +@pytest.fixture(scope="session") +async def client(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as c: + yield c + +def create_mock_jwt(user_id: str, exp_delta_hours: int = 24) -> str: + payload = { + "sub": user_id, + "exp": datetime.now(timezone.utc) + timedelta(hours=exp_delta_hours), + } + return jwt.encode(payload, settings.jwt_secret, algorithm="HS256") + +async def test_jwt_validation_invalid_signature(client): + """Test that a JWT with an invalid signature is rejected.""" + payload = { + "sub": str(uuid.uuid4()), + "exp": datetime.now(timezone.utc) + timedelta(hours=1), + } + invalid_token = jwt.encode(payload, "wrong_secret_key", algorithm="HS256") + + # We must use "Bearer " + response = await client.get( + "/user/photos", + headers={"Authorization": f"Bearer {invalid_token}"} + ) + assert response.status_code == 401 + assert "Invalid token" in response.text + +async def test_jwt_validation_expired_token(client): + """Test that an expired JWT is rejected.""" + expired_token = create_mock_jwt(str(uuid.uuid4()), exp_delta_hours=-1) + + response = await client.get( + "/user/photos", + headers={"Authorization": f"Bearer {expired_token}"} + ) + assert response.status_code == 401 + assert "Token has expired" in response.text + +async def test_blocked_user_access(client): + """Test that a blocked user cannot access protected endpoints.""" + async with engine.begin() as conn: + uq = user_queries.AsyncQuerier(conn) + user = await uq.create_user( + email=f"blocked-{uuid.uuid4()}@test.com", + hashed_password="hash" + ) + user_id = user.id + + # We need a session ID to put in the JWT, otherwise the auth dependency fails with "Invalid token" + session_id = uuid.uuid4() + + await uq.set_user_blocked(blocked=True, id=user_id) + + payload = { + "session_id": str(session_id), + "exp": datetime.now(timezone.utc) + timedelta(hours=1), + } + token = jwt.encode(payload, settings.jwt_secret, algorithm="HS256") + + try: + response = await client.get( + "/user/photos", + headers={"Authorization": f"Bearer {token}"} + ) + assert response.status_code in (401, 403), f"Expected 401 or 403, got {response.status_code}" + finally: + async with engine.begin() as conn: + await conn.execute(text(f"DELETE FROM users WHERE id = '{user_id}'")) + +async def test_rate_limiting(client): + """Test that multiple requests within a short timeframe hit rate limits.""" + async with engine.begin() as conn: + uq = user_queries.AsyncQuerier(conn) + user = await uq.create_user( + email=f"rate-{uuid.uuid4()}@test.com", + hashed_password="hash" + ) + user_id = user.id + session_id = uuid.uuid4() + + payload = { + "session_id": str(session_id), + "exp": datetime.now(timezone.utc) + timedelta(hours=1), + } + token = jwt.encode(payload, settings.jwt_secret, algorithm="HS256") + + try: + responses = [] + # We test with enough requests to hit the 20/min limit + for _ in range(25): + res = await client.get( + "/user/photos", + headers={"Authorization": f"Bearer {token}"} + ) + responses.append(res.status_code) + + assert 429 in responses, "Expected to hit rate limit (429) after multiple rapid requests" + finally: + async with engine.begin() as conn: + await conn.execute(text(f"DELETE FROM users WHERE id = '{user_id}'")) + try: + redis = RedisClient.get_instance() + await redis._client.delete("rate_limit:/user/photos:127.0.0.1") + await redis._client.delete("rate_limit:/user/photos:testclient") + except Exception: + pass diff --git a/tests/unit/test_auth_email_otp.py b/tests/unit/test_auth_email_otp.py new file mode 100644 index 00000000..9a6f825a --- /dev/null +++ b/tests/unit/test_auth_email_otp.py @@ -0,0 +1,171 @@ +import uuid +import json +from unittest.mock import AsyncMock, patch, ANY +import pytest + +from app.service.users import AuthService +from app.schema.request.mobile.auth import MobileRegisterRequest, RegisterVerifyRequest + +@pytest.fixture +def mock_user_querier() -> AsyncMock: + return AsyncMock() + +@pytest.fixture +def mock_device_querier() -> AsyncMock: + return AsyncMock() + +@pytest.fixture +def mock_session_querier() -> AsyncMock: + return AsyncMock() + +@pytest.fixture +def mock_face_embedding_service() -> AsyncMock: + return AsyncMock() + +@pytest.fixture +def mock_redis() -> AsyncMock: + return AsyncMock() + +@pytest.fixture +def auth_service( + mock_user_querier: AsyncMock, + mock_device_querier: AsyncMock, + mock_session_querier: AsyncMock, + mock_face_embedding_service: AsyncMock, +) -> AuthService: + return AuthService( + user_querier=mock_user_querier, + device_querier=mock_device_querier, + session_querier=mock_session_querier, + face_embedding_service=mock_face_embedding_service, + ) + +@pytest.mark.asyncio +@patch("app.service.users.NatsClient.publish") +async def test_mobile_register_sends_otp( + mock_publish: AsyncMock, + auth_service: AuthService, + mock_redis: AsyncMock, + mock_user_querier: AsyncMock, +) -> None: + # Arrange + req = MobileRegisterRequest( + email="test@example.com", + password="Password1!", + device_name="iPhone", + device_type="iOS", + device_id=uuid.uuid4(), + ) + mock_user_querier.get_user_by_email.return_value = None # User does not exist + mock_redis.incr.return_value = 1 # Rate limit check passes + + # Act + res = await auth_service.mobile_register(redis=mock_redis, req=req) + + # Assert + assert res.status == "pending_verification" + assert res.email == "test@example.com" + + # Verify redis was called to save pending user and OTP + assert mock_redis.set.call_count == 2 + + # Verify NATS publish was called + mock_publish.assert_called_once() + args, _ = mock_publish.call_args + assert args[0] == "email.send_otp" + payload = json.loads(args[1]) + assert payload["email"] == "test@example.com" + assert "otp" in payload + +@pytest.mark.asyncio +async def test_verify_mobile_register_success( + auth_service: AuthService, + mock_redis: AsyncMock, + mock_user_querier: AsyncMock, + mock_device_querier: AsyncMock, + mock_session_querier: AsyncMock, +) -> None: + # Arrange + device_id = uuid.uuid4() + req = RegisterVerifyRequest( + email="test@example.com", + password="Password1!", + device_name="iPhone", + device_type="iOS", + device_id=device_id, + otp="123456" + ) + + mock_redis.get.side_effect = [ + "123456", # First call gets OTP + json.dumps({"hashed_password": "hashed_pass"}) # Second call gets pending user + ] + + mock_user = AsyncMock() + mock_user.id = uuid.uuid4() + mock_user.email = "test@example.com" + mock_user.blocked = False + mock_user_querier.create_user.return_value = mock_user + + mock_session_querier.count_user_sessions.return_value = 0 + mock_session = AsyncMock() + mock_session.id = uuid.uuid4() + mock_session_querier.upsert_session.return_value = mock_session + mock_device_querier.get_device_by_id.return_value = None + + # Act + with patch("app.service.users.SessionService.cache_session_for_auth", new_callable=AsyncMock): + res = await auth_service.verify_mobile_register(redis=mock_redis, req=req) + + # Assert + assert res.is_new_user is True + assert res.user_id == mock_user.id + + # Verify user was created + mock_user_querier.create_user.assert_called_once_with(email="test@example.com", hashed_password="hashed_pass") + + # Verify redis cleanup + assert mock_redis.delete.call_count == 2 + + +@pytest.mark.asyncio +async def test_mobile_register_resend_otp_success( + auth_service: AuthService, + mock_redis: AsyncMock, +) -> None: + # Arrange + email = "test@example.com" + mock_redis.get.return_value = '{"hashed_password": "fake"}' + mock_redis.incr.return_value = 1 + + # Act + with patch("app.infra.nats.NatsClient.publish", new_callable=AsyncMock) as mock_publish: + res = await auth_service.mobile_register_resend_otp(redis=mock_redis, email=email) + + # Assert + assert res.status == "pending_verification" + assert res.message == "New OTP sent to email" + assert res.email == email + + mock_redis.get.assert_called_with(f"pending_user:{email}") + mock_redis.set.assert_called_with(f"otp:{email}", ANY, expire=600) + mock_publish.assert_called_once() + + +@pytest.mark.asyncio +async def test_mobile_register_resend_otp_not_found( + auth_service: AuthService, + mock_redis: AsyncMock, +) -> None: + from fastapi import HTTPException + # Arrange + email = "test@example.com" + mock_redis.incr.return_value = 1 + mock_redis.get.return_value = None # No pending user + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + await auth_service.mobile_register_resend_otp(redis=mock_redis, email=email) + + assert exc_info.value.status_code == 404 + assert "No pending registration found" in exc_info.value.detail diff --git a/tests/unit/test_auth_service.py b/tests/unit/test_auth_service.py new file mode 100644 index 00000000..2024ba61 --- /dev/null +++ b/tests/unit/test_auth_service.py @@ -0,0 +1,460 @@ +""" +Unit tests for AuthService. + +Tests cover the core mobile auth flow: login, registration, password validation, +blocked user enforcement, session limits, logout, refresh token, and face embedding. +All dependencies (DB queriers, Redis, FaceEmbeddingService) are mocked. +""" + +import uuid +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi.exceptions import HTTPException + +from app.service.users import AuthService +from app.core.securite import hash_password +from app.schema.request.mobile.auth import MobileLoginRequest, MobileRegisterRequest + + +# --------------------------------------------------------------------------- +# Factories +# --------------------------------------------------------------------------- + + +def _make_user( + *, + user_id: uuid.UUID | None = None, + email: str = "user@test.com", + password: str = "Secret123!", + blocked: bool = False, + face_embedding: str | None = None, +) -> MagicMock: + u = MagicMock() + u.id = user_id or uuid.uuid4() + u.email = email + u.hashed_password = hash_password(password) + u.blocked = blocked + u.face_embedding = face_embedding + u.display_name = None + return u + + +def _make_session( + *, + session_id: uuid.UUID | None = None, + user_id: uuid.UUID | None = None, + expires_at: datetime | None = None, +) -> MagicMock: + s = MagicMock() + s.id = session_id or uuid.uuid4() + s.user_id = user_id or uuid.uuid4() + s.device_id = uuid.uuid4() + s.expires_at = expires_at or datetime.now(timezone.utc) + timedelta(days=30) + s.last_active = datetime.now(timezone.utc) + return s + + +def _make_device() -> MagicMock: + d = MagicMock() + d.id = uuid.uuid4() + d.user_id = uuid.uuid4() + d.is_invalid_token = False + d.is_active = True + return d + + +def _make_login_request( + *, + email: str = "user@test.com", + password: str = "Secret123!", +) -> MobileLoginRequest: + return MobileLoginRequest( + email=email, + password=password, + device_id=uuid.uuid4(), + device_name="iPhone 15", + device_type="ios", + ) + + +def _make_register_request( + *, + email: str = "user@test.com", + password: str = "Secret123!", +) -> MobileRegisterRequest: + return MobileRegisterRequest( + email=email, + password=password, + device_id=uuid.uuid4(), + device_name="iPhone 15", + device_type="ios", + ) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def user_querier() -> AsyncMock: + from db.generated import user as user_queries + q = MagicMock(spec=user_queries.AsyncQuerier) + q.get_user_by_email = AsyncMock(return_value=None) + q.create_user = AsyncMock() + q.get_user_by_id = AsyncMock() + q.find_closest_user_by_embedding = AsyncMock(return_value=None) + q.set_user_embedding = AsyncMock() + return q + + +@pytest.fixture +def device_querier() -> AsyncMock: + from db.generated import devices as device_queries + q = MagicMock(spec=device_queries.AsyncQuerier) + q.get_device_by_id = AsyncMock(return_value=None) + q.create_device = AsyncMock(return_value=_make_device()) + q.activate_device = AsyncMock() + return q + + +@pytest.fixture +def session_querier() -> AsyncMock: + from db.generated import session as session_queries + q = MagicMock(spec=session_queries.AsyncQuerier) + q.count_user_sessions = AsyncMock(return_value=0) + q.upsert_session = AsyncMock(return_value=_make_session()) + q.get_session_by_id = AsyncMock() + return q + + +@pytest.fixture +def face_service() -> AsyncMock: + from app.service.face_embedding import FaceEmbeddingService + svc = MagicMock(spec=FaceEmbeddingService) + svc.compute_average_embedding = AsyncMock(return_value=[0.1] * 512) + return svc + + +@pytest.fixture +def redis() -> AsyncMock: + r = MagicMock() + r.set = AsyncMock() + r.get = AsyncMock(return_value=None) + r.delete = AsyncMock() + r.incr = AsyncMock(return_value=1) + r.expire = AsyncMock() + return r + + +@pytest.fixture +def auth_service( + user_querier: AsyncMock, + device_querier: AsyncMock, + session_querier: AsyncMock, + face_service: AsyncMock, +) -> AuthService: + return AuthService( + user_querier=user_querier, + device_querier=device_querier, + session_querier=session_querier, + face_embedding_service=face_service, + ) + + +# =========================================================================== +# 1. Registration — new user +# =========================================================================== + + +class TestRegisterNewUser: + @pytest.mark.asyncio + async def test_new_user_is_created( + self, + auth_service: AuthService, + user_querier: AsyncMock, + redis: AsyncMock, + ) -> None: + new_user = _make_user() + user_querier.get_user_by_email.return_value = None + user_querier.create_user.return_value = new_user + + req = _make_register_request() + result = await auth_service.mobile_register(redis, req) + + user_querier.create_user.assert_not_called() + assert result.status == "pending_verification" + + @pytest.mark.asyncio + async def test_pending_status_returned_on_register( + self, + auth_service: AuthService, + user_querier: AsyncMock, + redis: AsyncMock, + ) -> None: + user_querier.get_user_by_email.return_value = None + + result = await auth_service.mobile_register(redis, _make_register_request()) + + assert result.status == "pending_verification" + assert result.message == "OTP sent to email" + + @pytest.mark.asyncio + async def test_session_cached_in_redis_on_register( + self, + auth_service: AuthService, + user_querier: AsyncMock, + redis: AsyncMock, + ) -> None: + new_user = _make_user() + user_querier.get_user_by_email.return_value = None + user_querier.create_user.return_value = new_user + + await auth_service.mobile_register(redis, _make_register_request()) + + # Redis.set must be called at least once (session key) + redis.set.assert_called() + + +# =========================================================================== +# 2. Login — existing user +# =========================================================================== + + +class TestLoginExistingUser: + @pytest.mark.asyncio + async def test_valid_credentials_return_tokens( + self, + auth_service: AuthService, + user_querier: AsyncMock, + redis: AsyncMock, + ) -> None: + existing = _make_user(password="Correctpass1!") + user_querier.get_user_by_email.return_value = existing + + result = await auth_service.mobile_login( + redis, _make_login_request(password="Correctpass1!") + ) + + assert result.is_new_user is False + assert result.access_token + + @pytest.mark.asyncio + async def test_wrong_password_raises_401( + self, + auth_service: AuthService, + user_querier: AsyncMock, + redis: AsyncMock, + ) -> None: + existing = _make_user(password="Rightpassword1!") + user_querier.get_user_by_email.return_value = existing + + with pytest.raises(HTTPException) as exc_info: + await auth_service.mobile_login( + redis, _make_login_request(password="Wrongpassword1!") + ) + assert exc_info.value.status_code == 401 + + @pytest.mark.asyncio + async def test_blocked_user_raises_403( + self, + auth_service: AuthService, + user_querier: AsyncMock, + redis: AsyncMock, + ) -> None: + blocked = _make_user(password="Secret123!", blocked=True) + user_querier.get_user_by_email.return_value = blocked + + with pytest.raises(HTTPException) as exc_info: + await auth_service.mobile_login(redis, _make_login_request()) + assert exc_info.value.status_code == 403 + + +# =========================================================================== +# 3. Session limit enforcement +# =========================================================================== + + +class TestSessionLimit: + @pytest.mark.asyncio + async def test_exceeding_session_limit_raises_403( + self, + auth_service: AuthService, + user_querier: AsyncMock, + session_querier: AsyncMock, + redis: AsyncMock, + ) -> None: + user = _make_user() + user_querier.get_user_by_email.return_value = user + # Return a count >= SESSION_LIMIT + session_querier.count_user_sessions.return_value = AuthService.SESSION_LIMIT + + with pytest.raises(HTTPException) as exc_info: + await auth_service.mobile_login(redis, _make_login_request()) + assert exc_info.value.status_code == 403 + assert "session limit" in exc_info.value.detail.lower() + + @pytest.mark.asyncio + async def test_within_session_limit_succeeds( + self, + auth_service: AuthService, + user_querier: AsyncMock, + session_querier: AsyncMock, + redis: AsyncMock, + ) -> None: + user = _make_user() + user_querier.get_user_by_email.return_value = user + session_querier.count_user_sessions.return_value = AuthService.SESSION_LIMIT - 1 + + result = await auth_service.mobile_login(redis, _make_login_request()) + assert result.access_token + + +# =========================================================================== +# 4. Logout +# =========================================================================== + + +class TestLogout: + @pytest.mark.asyncio + async def test_logout_deletes_session_key_from_redis( + self, + auth_service: AuthService, + redis: AsyncMock, + ) -> None: + user_id = str(uuid.uuid4()) + session_id = str(uuid.uuid4()) + + await auth_service.logout(redis, user_id, session_id) + + redis.delete.assert_called_once() + key_used = redis.delete.call_args.args[0] + assert user_id in key_used + + @pytest.mark.asyncio + async def test_logout_returns_success_message( + self, + auth_service: AuthService, + redis: AsyncMock, + ) -> None: + result = await auth_service.logout(redis, str(uuid.uuid4()), str(uuid.uuid4())) + assert "message" in result + assert "logged out" in result["message"].lower() + + +# =========================================================================== +# 5. Refresh token +# =========================================================================== + + +class TestRefreshToken: + @pytest.mark.asyncio + async def test_valid_refresh_returns_new_tokens( + self, + auth_service: AuthService, + user_querier: AsyncMock, + session_querier: AsyncMock, + redis: AsyncMock, + ) -> None: + from app.core.securite import create_refresh_mobile_token + + session = _make_session() + session_querier.get_session_by_id.return_value = session + user_querier.get_user_by_id.return_value = _make_user(user_id=session.user_id) + + refresh_token = create_refresh_mobile_token(str(session.id)) + result = await auth_service.refresh_token(redis, refresh_token) + + assert result.access_token + assert result.refresh_token + + @pytest.mark.asyncio + async def test_expired_session_raises_401( + self, + auth_service: AuthService, + user_querier: AsyncMock, + session_querier: AsyncMock, + redis: AsyncMock, + ) -> None: + from app.core.securite import create_refresh_mobile_token + + past_session = _make_session( + expires_at=datetime.now(timezone.utc) - timedelta(days=1) + ) + session_querier.get_session_by_id.return_value = past_session + + refresh_token = create_refresh_mobile_token(str(past_session.id)) + + with pytest.raises(HTTPException) as exc_info: + await auth_service.refresh_token(redis, refresh_token) + assert exc_info.value.status_code == 401 + + @pytest.mark.asyncio + async def test_blocked_user_on_refresh_raises_403( + self, + auth_service: AuthService, + user_querier: AsyncMock, + session_querier: AsyncMock, + redis: AsyncMock, + ) -> None: + from app.core.securite import create_refresh_mobile_token + + session = _make_session() + session_querier.get_session_by_id.return_value = session + user_querier.get_user_by_id.return_value = _make_user( + user_id=session.user_id, blocked=True + ) + + refresh_token = create_refresh_mobile_token(str(session.id)) + + with pytest.raises(HTTPException) as exc_info: + await auth_service.refresh_token(redis, refresh_token) + assert exc_info.value.status_code == 403 + + @pytest.mark.asyncio + async def test_invalid_refresh_token_raises_401( + self, + auth_service: AuthService, + redis: AsyncMock, + ) -> None: + with pytest.raises(HTTPException) as exc_info: + await auth_service.refresh_token(redis, "completely.invalid.token") + assert exc_info.value.status_code == 401 + + +# =========================================================================== +# 6. find_closest_user +# =========================================================================== + + +class TestFindClosestUser: + @pytest.mark.asyncio + async def test_returns_none_when_no_row( + self, + auth_service: AuthService, + user_querier: AsyncMock, + ) -> None: + user_querier.find_closest_user_by_embedding.return_value = None + + result = await auth_service.find_closest_user(embedding_literal="[0.1, 0.2]") + + assert result is None + + @pytest.mark.asyncio + async def test_returns_closest_user_match( + self, + auth_service: AuthService, + user_querier: AsyncMock, + ) -> None: + row = MagicMock() + row.id = uuid.uuid4() + row.distance = 0.25 + user_querier.find_closest_user_by_embedding.return_value = row + + result = await auth_service.find_closest_user(embedding_literal="[0.1, 0.2]") + + assert result is not None + assert result.user_id == row.id + assert result.distance == 0.25 diff --git a/tests/unit/test_enroll_security.py b/tests/unit/test_enroll_security.py new file mode 100644 index 00000000..4f31584d --- /dev/null +++ b/tests/unit/test_enroll_security.py @@ -0,0 +1,306 @@ +""" +Unit tests for the enrollment security helpers introduced in fix/enroll. + +These helpers only exist on the fix/enroll branch. On other branches, +all tests are automatically skipped via pytest.importorskip. + +Run with: uv run pytest tests/unit/test_enroll_security.py -v +""" + +import io +import uuid +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import UploadFile +from fastapi.exceptions import HTTPException + +# Guard: skip gracefully if the helpers don't exist on this branch +_router_mod = pytest.importorskip( + "app.router.mobile.enrollement", + reason="fix/enroll branch required for enrollment security helpers", +) + +_sanitise_filename = getattr(_router_mod, "_sanitise_filename", None) +_validate_dimensions = getattr(_router_mod, "_validate_dimensions", None) +_precheck_upload_headers = getattr(_router_mod, "_precheck_upload_headers", None) +_enrollment_lock_key = getattr(_router_mod, "_enrollment_lock_key", None) +read_limited = getattr(_router_mod, "read_limited", None) + +# If any helper is missing, skip the whole module +if any(fn is None for fn in [_sanitise_filename, _validate_dimensions, + _precheck_upload_headers, _enrollment_lock_key, read_limited]): + pytest.skip( + "Enrollment security helpers not available on this branch", + allow_module_level=True, + ) + +from app.core.constant import MAX_IMAGE_SIZE, MIN_IMAGE_DIM, MAX_IMAGE_DIM # noqa: E402 + + +# --------------------------------------------------------------------------- +# Factories +# --------------------------------------------------------------------------- + + +def _make_jpeg_bytes(width: int = 200, height: int = 200) -> bytes: + """Generate a minimal valid JPEG in memory using Pillow.""" + from PIL import Image + + img = Image.new("RGB", (width, height), color=(100, 149, 237)) + buf = io.BytesIO() + img.save(buf, format="JPEG") + return buf.getvalue() + + +def _make_upload_file( + content: bytes, + filename: str = "face.jpg", + content_type: str | None = "image/jpeg", + content_length: int | None = None, +) -> UploadFile: + headers: dict[str, str] = {} + if content_length is not None: + headers["content-length"] = str(content_length) + + buf = io.BytesIO(content) + mock_file = MagicMock(spec=UploadFile) + mock_file.filename = filename + mock_file.content_type = content_type + mock_file.headers = headers + mock_file.read = AsyncMock(side_effect=lambda n=-1: buf.read(n) if n == -1 else buf.read(n)) + mock_file.seek = AsyncMock(side_effect=lambda pos: buf.seek(pos)) + return mock_file # type: ignore[return-value] + + +# =========================================================================== +# 1. _sanitise_filename +# =========================================================================== + + +class TestSanitiseFilename: + def test_normal_name_gets_uuid_prefix(self) -> None: + result = _sanitise_filename("portrait.jpg", "jpg") + parts = result.split("_", 1) + assert len(parts) == 2 + uuid.UUID(parts[0]) # raises if not a valid UUID + assert parts[1] == "portrait.jpg" + + def test_path_traversal_is_neutralised(self) -> None: + result = _sanitise_filename("../../../etc/passwd.jpg", "jpg") + assert ".." not in result + assert "/" not in result + + def test_null_bytes_are_replaced(self) -> None: + assert "\x00" not in _sanitise_filename("face\x00evil.jpg", "jpg") + + def test_control_characters_are_replaced(self) -> None: + assert "\x1f" not in _sanitise_filename("face\x1fmalicious.jpg", "jpg") + + def test_windows_reserved_chars_are_replaced(self) -> None: + for char in r'\/:*?"<>|': + assert char not in _sanitise_filename(f"face{char}name.jpg", "jpg"), \ + f"char {char!r} must be replaced" + + def test_none_filename_returns_uuid_only(self) -> None: + result = _sanitise_filename(None, "png") + base, ext = result.rsplit(".", 1) + uuid.UUID(base) + assert ext == "png" + + def test_empty_filename_returns_uuid_only(self) -> None: + result = _sanitise_filename("", "jpg") + base, ext = result.rsplit(".", 1) + uuid.UUID(base) + assert ext == "jpg" + + def test_long_filename_is_truncated(self) -> None: + result = _sanitise_filename("a" * 200, "jpg") + name_part = result.split("_", 1)[1] + assert len(name_part) <= 128 + + def test_leading_dots_stripped(self) -> None: + result = _sanitise_filename("...hidden.jpg", "jpg") + name_part = result.split("_", 1)[1] + assert not name_part.startswith(".") + + +# =========================================================================== +# 2. _validate_dimensions +# =========================================================================== + + +class TestValidateDimensions: + def test_valid_image_passes(self) -> None: + _validate_dimensions(_make_jpeg_bytes(200, 200)) # must not raise + + def test_too_small_width_raises_400(self) -> None: + with pytest.raises(HTTPException) as exc_info: + _validate_dimensions(_make_jpeg_bytes(MIN_IMAGE_DIM - 1, 200)) + assert exc_info.value.status_code == 400 + assert "too small" in exc_info.value.detail.lower() + + def test_too_small_height_raises_400(self) -> None: + with pytest.raises(HTTPException) as exc_info: + _validate_dimensions(_make_jpeg_bytes(200, MIN_IMAGE_DIM - 1)) + assert exc_info.value.status_code == 400 + + def test_too_large_width_raises_400(self) -> None: + with pytest.raises(HTTPException) as exc_info: + _validate_dimensions(_make_jpeg_bytes(MAX_IMAGE_DIM + 1, 200)) + assert exc_info.value.status_code == 400 + assert "too large" in exc_info.value.detail.lower() + + def test_corrupt_bytes_raises_400(self) -> None: + with pytest.raises(HTTPException) as exc_info: + _validate_dimensions(b"this-is-not-an-image") + assert exc_info.value.status_code == 400 + + def test_boundary_min_dimension_passes(self) -> None: + _validate_dimensions(_make_jpeg_bytes(MIN_IMAGE_DIM, MIN_IMAGE_DIM)) + + def test_boundary_max_dimension_passes(self) -> None: + _validate_dimensions(_make_jpeg_bytes(MAX_IMAGE_DIM, MAX_IMAGE_DIM)) + + +# =========================================================================== +# 3. _precheck_upload_headers +# =========================================================================== + + +class TestPrecheckUploadHeaders: + def test_valid_jpeg_header_passes(self) -> None: + _precheck_upload_headers(_make_upload_file(b"", content_type="image/jpeg")) + + def test_valid_png_header_passes(self) -> None: + _precheck_upload_headers(_make_upload_file(b"", content_type="image/png")) + + def test_missing_content_type_raises_400(self) -> None: + f = _make_upload_file(b"", content_type=None) + with pytest.raises(HTTPException) as exc_info: + _precheck_upload_headers(f) + assert exc_info.value.status_code == 400 + + def test_unsupported_content_type_raises_400(self) -> None: + with pytest.raises(HTTPException) as exc_info: + _precheck_upload_headers(_make_upload_file(b"", content_type="application/pdf")) + assert exc_info.value.status_code == 400 + + def test_content_type_with_charset_param_accepted(self) -> None: + # "image/jpeg; charset=utf-8" should normalise to "image/jpeg" + _precheck_upload_headers( + _make_upload_file(b"", content_type="image/jpeg; charset=utf-8") + ) + + def test_oversized_content_length_raises_400(self) -> None: + with pytest.raises(HTTPException) as exc_info: + _precheck_upload_headers( + _make_upload_file(b"", content_type="image/jpeg", content_length=MAX_IMAGE_SIZE + 1) + ) + assert exc_info.value.status_code == 400 + + def test_valid_content_length_passes(self) -> None: + _precheck_upload_headers( + _make_upload_file(b"", content_type="image/jpeg", content_length=1024) + ) + + def test_invalid_content_length_string_raises_400(self) -> None: + f = _make_upload_file(b"", content_type="image/jpeg") + f.headers = {"content-length": "not_a_number"} + with pytest.raises(HTTPException) as exc_info: + _precheck_upload_headers(f) + assert exc_info.value.status_code == 400 + + +# =========================================================================== +# 4. read_limited +# =========================================================================== + + +class TestReadLimited: + @pytest.mark.asyncio + async def test_small_file_returns_full_bytes(self) -> None: + data = b"hello world" + result = await read_limited(_make_upload_file(data), MAX_IMAGE_SIZE) + assert result == data + + @pytest.mark.asyncio + async def test_exceeds_limit_raises_400(self) -> None: + data = b"x" * (MAX_IMAGE_SIZE + 1) + with pytest.raises(HTTPException) as exc_info: + await read_limited(_make_upload_file(data), MAX_IMAGE_SIZE) + assert exc_info.value.status_code == 400 + + @pytest.mark.asyncio + async def test_exactly_at_limit_passes(self) -> None: + data = b"x" * MAX_IMAGE_SIZE + result = await read_limited(_make_upload_file(data), MAX_IMAGE_SIZE) + assert len(result) == MAX_IMAGE_SIZE + + @pytest.mark.asyncio + async def test_empty_file_returns_empty_bytes(self) -> None: + result = await read_limited(_make_upload_file(b""), MAX_IMAGE_SIZE) + assert result == b"" + + @pytest.mark.asyncio + async def test_aborts_early_without_reading_entire_stream(self) -> None: + """Must abort after exceeding the limit — not exhaust the stream.""" + call_count = 0 + chunk = b"x" * 65536 + + async def mock_read(n: int = -1) -> bytes: + nonlocal call_count + call_count += 1 + if call_count > 10: + return b"" + return chunk + + f = MagicMock(spec=UploadFile) + f.read = mock_read + f.seek = AsyncMock() + + limit = 65536 * 5 # 5 chunks + with pytest.raises(HTTPException): + await read_limited(f, limit) + + assert call_count <= 7, "read_limited must abort early" + + +# =========================================================================== +# 5. _enrollment_lock_key +# =========================================================================== + + +class TestEnrollmentLockKey: + def test_key_contains_user_id(self) -> None: + user_id = uuid.uuid4() + assert str(user_id) in _enrollment_lock_key(user_id) + + def test_different_users_have_different_keys(self) -> None: + assert _enrollment_lock_key(uuid.uuid4()) != _enrollment_lock_key(uuid.uuid4()) + + def test_key_format(self) -> None: + user_id = uuid.uuid4() + assert _enrollment_lock_key(user_id) == f"enroll:in_progress:{user_id}" + + +# =========================================================================== +# 6. Magic byte sniffing (unit-level verification) +# =========================================================================== + + +class TestMagicByteSniffing: + def test_pdf_bytes_not_classified_as_image(self) -> None: + import filetype # type: ignore[import-untyped] + + pdf_bytes = b"%PDF-1.4 fake pdf content" + kind = filetype.guess(pdf_bytes) + allowed = {"image/jpeg", "image/png", "image/heic", "image/heif"} + assert kind is None or kind.mime not in allowed + + def test_real_jpeg_classified_as_jpeg(self) -> None: + import filetype # type: ignore[import-untyped] + + kind = filetype.guess(_make_jpeg_bytes(100, 100)) + assert kind is not None + assert kind.mime == "image/jpeg" diff --git a/tests/unit/test_face_match_service.py b/tests/unit/test_face_match_service.py new file mode 100644 index 00000000..8de215b0 --- /dev/null +++ b/tests/unit/test_face_match_service.py @@ -0,0 +1,404 @@ +""" +Unit tests for SingleFaceMatchService. + +All database and service collaborators are mocked — no live infrastructure required. +Tests cover all decision branches: auto-approve, threshold rejection, happy-path match, +idempotency guards, and notification side-effects. +""" + +import json +import uuid +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.service.face_match import SingleFaceMatchService +from app.schema.internal.single_face_match import BBoxPayload, SingleFaceMatchJob + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def photo_face_querier() -> AsyncMock: + from db.generated import photo_faces as pf_queries + q = MagicMock(spec=pf_queries.AsyncQuerier) + q.photo_faces_photo_exists = AsyncMock(return_value=object()) # truthy → photo exists + q.photo_faces_match_exists_for_photo = AsyncMock(return_value=None) # no duplicate + q.photo_faces_ensure_face_match = AsyncMock() + return q + + +@pytest.fixture +def photo_querier() -> AsyncMock: + from db.generated import photos as photo_queries + q = MagicMock(spec=photo_queries.AsyncQuerier) + q.update_photo_status = AsyncMock(return_value=None) + return q + + +@pytest.fixture +def user_match_service() -> AsyncMock: + from app.service.users import AuthService + svc = MagicMock(spec=AuthService) + svc.find_closest_user = AsyncMock() + return svc + + +@pytest.fixture +def notification_service() -> AsyncMock: + from app.service.user_notification import UserNotificationService + svc = MagicMock(spec=UserNotificationService) + svc.create_notification = AsyncMock() + return svc + + +@pytest.fixture +def service( + photo_face_querier: AsyncMock, + photo_querier: AsyncMock, + user_match_service: AsyncMock, + notification_service: AsyncMock, +) -> SingleFaceMatchService: + return SingleFaceMatchService( + conn=MagicMock(), + photo_face_querier=photo_face_querier, + photo_querier=photo_querier, + user_match_service=user_match_service, + user_notification_service=notification_service, + ) + + +@pytest.fixture +def job() -> SingleFaceMatchJob: + return SingleFaceMatchJob( + photo_id=uuid.uuid4(), + image_ref="photos/test.jpg", + face_index=0, + ) + + +@pytest.fixture +def bbox() -> BBoxPayload: + return BBoxPayload(x1=10.0, y1=20.0, x2=100.0, y2=200.0) + + +def _make_embedding(value: float = 0.5) -> list[float]: + return [value] * 512 + + +def _closest_match(distance: float = 0.3) -> object: + from app.schema.internal.single_face_match import ClosestUserMatch + return ClosestUserMatch(user_id=uuid.uuid4(), distance=distance) + + +# =========================================================================== +# 1. Auto-approve — no enrolled users in DB +# =========================================================================== + + +class TestAutoApproveNoUsers: + @pytest.mark.asyncio + async def test_photo_approved_when_no_users( + self, + service: SingleFaceMatchService, + job: SingleFaceMatchJob, + photo_querier: AsyncMock, + user_match_service: AsyncMock, + ) -> None: + user_match_service.find_closest_user.return_value = None # empty DB + + await service.process_detected_face(job, _make_embedding(), bbox=None) + + photo_querier.update_photo_status.assert_called_once_with( + id=job.photo_id, status="approved" + ) + + @pytest.mark.asyncio + async def test_no_notification_when_auto_approved( + self, + service: SingleFaceMatchService, + job: SingleFaceMatchJob, + user_match_service: AsyncMock, + notification_service: AsyncMock, + ) -> None: + user_match_service.find_closest_user.return_value = None + + await service.process_detected_face(job, _make_embedding(), bbox=None) + + notification_service.create_notification.assert_not_called() + + +# =========================================================================== +# 2. Auto-approve — distance above threshold +# =========================================================================== + + +class TestAutoApproveDistanceThreshold: + @pytest.mark.asyncio + async def test_photo_approved_when_distance_exceeds_threshold( + self, + service: SingleFaceMatchService, + job: SingleFaceMatchJob, + photo_querier: AsyncMock, + user_match_service: AsyncMock, + ) -> None: + from app.worker.photo_worker.settings import settings as worker_settings + + # Distance just above the threshold → no match → auto-approve + bad_match = _closest_match(distance=worker_settings.similarity_threshold + 0.01) + user_match_service.find_closest_user.return_value = bad_match + + await service.process_detected_face(job, _make_embedding(), bbox=None) + + photo_querier.update_photo_status.assert_called_once_with( + id=job.photo_id, status="approved" + ) + + @pytest.mark.asyncio + async def test_no_db_write_when_distance_exceeds_threshold( + self, + service: SingleFaceMatchService, + job: SingleFaceMatchJob, + photo_face_querier: AsyncMock, + user_match_service: AsyncMock, + ) -> None: + from app.worker.photo_worker.settings import settings as worker_settings + + bad_match = _closest_match(distance=worker_settings.similarity_threshold + 0.01) + user_match_service.find_closest_user.return_value = bad_match + + await service.process_detected_face(job, _make_embedding(), bbox=None) + + photo_face_querier.photo_faces_ensure_face_match.assert_not_called() + + +# =========================================================================== +# 3. Happy-path match → notification sent +# =========================================================================== + + +class TestSuccessfulMatch: + @pytest.mark.asyncio + async def test_face_match_stored_in_db( + self, + service: SingleFaceMatchService, + job: SingleFaceMatchJob, + photo_face_querier: AsyncMock, + user_match_service: AsyncMock, + ) -> None: + good_match = _closest_match(distance=0.1) + user_match_service.find_closest_user.return_value = good_match + + face_match_result = MagicMock() + face_match_result.face_match_id = uuid.uuid4() + photo_face_querier.photo_faces_ensure_face_match.return_value = face_match_result + + await service.process_detected_face(job, _make_embedding(), bbox=None) + + photo_face_querier.photo_faces_ensure_face_match.assert_called_once() + + @pytest.mark.asyncio + async def test_photo_status_set_to_approved_on_match( + self, + service: SingleFaceMatchService, + job: SingleFaceMatchJob, + photo_face_querier: AsyncMock, + photo_querier: AsyncMock, + user_match_service: AsyncMock, + ) -> None: + good_match = _closest_match(distance=0.1) + user_match_service.find_closest_user.return_value = good_match + + face_match_result = MagicMock() + face_match_result.face_match_id = uuid.uuid4() + photo_face_querier.photo_faces_ensure_face_match.return_value = face_match_result + + await service.process_detected_face(job, _make_embedding(), bbox=None) + + photo_querier.update_photo_status.assert_called_once_with( + id=job.photo_id, status="approved" + ) + + @pytest.mark.asyncio + async def test_notification_sent_on_successful_match( + self, + service: SingleFaceMatchService, + job: SingleFaceMatchJob, + photo_face_querier: AsyncMock, + user_match_service: AsyncMock, + notification_service: AsyncMock, + ) -> None: + good_match = _closest_match(distance=0.1) + user_match_service.find_closest_user.return_value = good_match + + face_match_result = MagicMock() + face_match_result.face_match_id = uuid.uuid4() + photo_face_querier.photo_faces_ensure_face_match.return_value = face_match_result + + await service.process_detected_face(job, _make_embedding(), bbox=None) + + notification_service.create_notification.assert_called_once() + call_kwargs = notification_service.create_notification.call_args.kwargs + assert call_kwargs["type"] == "face_match" + assert call_kwargs["user_id"] == good_match.user_id # type: ignore[union-attr] + + @pytest.mark.asyncio + async def test_bbox_serialised_as_json( + self, + service: SingleFaceMatchService, + job: SingleFaceMatchJob, + photo_face_querier: AsyncMock, + user_match_service: AsyncMock, + bbox: BBoxPayload, + ) -> None: + good_match = _closest_match(distance=0.1) + user_match_service.find_closest_user.return_value = good_match + + face_match_result = MagicMock() + face_match_result.face_match_id = uuid.uuid4() + photo_face_querier.photo_faces_ensure_face_match.return_value = face_match_result + + await service.process_detected_face(job, _make_embedding(), bbox=bbox) + + call_args = photo_face_querier.photo_faces_ensure_face_match.call_args + params = call_args.args[0] + parsed_bbox = json.loads(params.bbox) + assert parsed_bbox == {"x1": 10.0, "y1": 20.0, "x2": 100.0, "y2": 200.0} + + +# =========================================================================== +# 4. Idempotency guards +# =========================================================================== + + +class TestIdempotencyGuards: + @pytest.mark.asyncio + async def test_skips_if_photo_not_found( + self, + service: SingleFaceMatchService, + job: SingleFaceMatchJob, + photo_face_querier: AsyncMock, + photo_querier: AsyncMock, + ) -> None: + photo_face_querier.photo_faces_photo_exists.return_value = None # not found + + await service.process_detected_face(job, _make_embedding(), bbox=None) + + photo_querier.update_photo_status.assert_not_called() + photo_face_querier.photo_faces_ensure_face_match.assert_not_called() + + @pytest.mark.asyncio + async def test_skips_if_match_already_exists( + self, + service: SingleFaceMatchService, + job: SingleFaceMatchJob, + photo_face_querier: AsyncMock, + photo_querier: AsyncMock, + ) -> None: + photo_face_querier.photo_faces_match_exists_for_photo.return_value = object() # exists + + await service.process_detected_face(job, _make_embedding(), bbox=None) + + photo_querier.update_photo_status.assert_not_called() + photo_face_querier.photo_faces_ensure_face_match.assert_not_called() + + @pytest.mark.asyncio + async def test_skips_if_missing_image_ref( + self, + service: SingleFaceMatchService, + photo_querier: AsyncMock, + ) -> None: + job_no_ref = SingleFaceMatchJob( + photo_id=uuid.uuid4(), + image_ref="", # empty + face_index=0, + ) + + await service.process_detected_face(job_no_ref, _make_embedding(), bbox=None) + + photo_querier.update_photo_status.assert_not_called() + + @pytest.mark.asyncio + async def test_no_notification_if_face_match_already_existed( + self, + service: SingleFaceMatchService, + job: SingleFaceMatchJob, + photo_face_querier: AsyncMock, + user_match_service: AsyncMock, + notification_service: AsyncMock, + ) -> None: + """If ensure_face_match returns a result with face_match_id=None, it means + the match was already there — no duplicate notification should be sent.""" + good_match = _closest_match(distance=0.1) + user_match_service.find_closest_user.return_value = good_match + + result_already_existed = MagicMock() + result_already_existed.face_match_id = None # already existed + photo_face_querier.photo_faces_ensure_face_match.return_value = result_already_existed + + await service.process_detected_face(job, _make_embedding(), bbox=None) + + notification_service.create_notification.assert_not_called() + + +# =========================================================================== +# 5. Resilience — DB errors don't crash the worker +# =========================================================================== + + +class TestResilience: + @pytest.mark.asyncio + async def test_db_error_is_handled_gracefully( + self, + service: SingleFaceMatchService, + job: SingleFaceMatchJob, + photo_face_querier: AsyncMock, + user_match_service: AsyncMock, + notification_service: AsyncMock, + ) -> None: + from sqlalchemy.exc import SQLAlchemyError + + good_match = _closest_match(distance=0.1) + user_match_service.find_closest_user.return_value = good_match + photo_face_querier.photo_faces_ensure_face_match.side_effect = SQLAlchemyError("DB down") + + # Should not raise — worker must stay alive + await service.process_detected_face(job, _make_embedding(), bbox=None) + notification_service.create_notification.assert_not_called() + + @pytest.mark.asyncio + async def test_memory_error_is_handled_gracefully( + self, + service: SingleFaceMatchService, + job: SingleFaceMatchJob, + user_match_service: AsyncMock, + ) -> None: + user_match_service.find_closest_user.side_effect = MemoryError("OOM") + + # Should not raise + await service.process_detected_face(job, _make_embedding(), bbox=None) + + +# =========================================================================== +# 6. Static helpers +# =========================================================================== + + +class TestStaticHelpers: + def test_vector_literal_format(self) -> None: + embedding = [0.1, 0.2, 0.3] + result = SingleFaceMatchService._vector_literal(embedding) + assert result == "[0.1, 0.2, 0.3]" + + def test_serialize_bbox_none_returns_none(self) -> None: + assert SingleFaceMatchService._serialize_bbox(None) is None + + def test_serialize_bbox_valid(self) -> None: + bbox = BBoxPayload(x1=1.0, y1=2.0, x2=3.0, y2=4.0) + result = SingleFaceMatchService._serialize_bbox(bbox) + assert result is not None + parsed = json.loads(result) + assert parsed == {"x1": 1.0, "y1": 2.0, "x2": 3.0, "y2": 4.0} diff --git a/tests/unit/test_mobile_auth_email_logging.py b/tests/unit/test_mobile_auth_email_logging.py index 696e095b..1d3b11a3 100644 --- a/tests/unit/test_mobile_auth_email_logging.py +++ b/tests/unit/test_mobile_auth_email_logging.py @@ -133,7 +133,5 @@ async def _noop_cache_session_for_auth(**_: object) -> None: # 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 + assert "mobile_register attempt" in caplog.text diff --git a/tests/unit/test_mobile_auth_intent_validation.py b/tests/unit/test_mobile_auth_intent_validation.py index 0f81e97d..4d58ce16 100644 --- a/tests/unit/test_mobile_auth_intent_validation.py +++ b/tests/unit/test_mobile_auth_intent_validation.py @@ -113,6 +113,9 @@ async def ttl(self, key: str) -> int: async def set(self, key: str, value: str, expire: int) -> None: return None + async def delete(self, key: str) -> None: + self._store.pop(key, None) + class FakeFaceEmbeddingService: pass @@ -233,9 +236,7 @@ async def _noop_cache_session_for_auth(**_: object) -> None: 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 + assert result.status == "pending_verification" def test_register_then_login_same_device_succeeds( @@ -270,13 +271,38 @@ async def _noop_cache_session_for_auth(**_: object) -> None: device_type="android", device_id=device_id, ) - result1 = asyncio.run(service.mobile_register(FakeRedis(), register_req)) - assert result1.is_new_user is True + fake_redis = FakeRedis() + result1 = asyncio.run(service.mobile_register(fake_redis, register_req)) + assert result1.status == "pending_verification" + + # Try to register again (should just resend OTP, not fail with 409 because user is not yet fully enrolled) + # Actually, we expect 200 with pending_verification again if they try to register while pending, OR it might + # just succeed. Wait, the actual flow drops it in Redis and returns pending. So it won't raise 409 unless + # the user is IN THE DB. Since FakeUserQuerier won't have it, it won't raise 409. + # We will just verify OTP instead. + + # Now verify to fully create user + from app.schema.request.mobile.auth import RegisterVerifyRequest + verify_req = RegisterVerifyRequest( + email="newuser@example.com", + password=password, + otp="123456", + device_name="TestDevice", + device_type="android", + device_id=device_id, + ) + # Fake Redis returning the raw data and otp + import json + fake_redis._store["otp:newuser@example.com"] = "123456" + fake_redis._store["pending_user:newuser@example.com"] = json.dumps({"hashed_password": hash_password(password)}) - # 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 + # We have to stub get() on FakeRedis since it doesn't support it by default + async def fake_get(key: str) -> str | None: + return fake_redis._store.get(key) + fake_redis.get = fake_get # type: ignore + + verify_result = asyncio.run(service.verify_mobile_register(fake_redis, verify_req)) + assert verify_result.is_new_user is True # Now login login_req = MobileLoginRequest( @@ -384,16 +410,28 @@ async def _raise_integrity_error(*args: Any, **kwargs: Any) -> Any: face_embedding_service=FakeFaceEmbeddingService(), ) - req = MobileRegisterRequest( + # Stub create_user to raise IntegrityError during VERIFY, because mobile_register + # no longer calls create_user directly! + from app.schema.request.mobile.auth import RegisterVerifyRequest + verify_req = RegisterVerifyRequest( email="newuser@example.com", password="ValidPass@123", + otp="123456", device_name="Pixel 8", device_type="android", device_id=uuid.uuid4(), ) + fake_redis = FakeRedis() + import json + fake_redis._store["otp:newuser@example.com"] = "123456" + fake_redis._store["pending_user:newuser@example.com"] = json.dumps({"hashed_password": hash_password("ValidPass@123")}) + async def fake_get(key: str) -> str | None: + return fake_redis._store.get(key) + fake_redis.get = fake_get # type: ignore + with pytest.raises(HTTPException) as exc_info: - asyncio.run(service.mobile_register(FakeRedis(), req)) + asyncio.run(service.verify_mobile_register(fake_redis, verify_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_request_validation.py b/tests/unit/test_mobile_auth_request_validation.py index eb61902c..56d3a82e 100644 --- a/tests/unit/test_mobile_auth_request_validation.py +++ b/tests/unit/test_mobile_auth_request_validation.py @@ -4,11 +4,12 @@ import pytest from fastapi.testclient import TestClient +from unittest.mock import AsyncMock, patch 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 +from app.schema.response.mobile.auth import MobileAuthResponse, RegisterPendingResponse class FakeAuthService: @@ -23,16 +24,13 @@ async def mobile_register( redis: object, req: MobileRegisterRequest, client_ip: object = None, - ) -> MobileAuthResponse: + ) -> RegisterPendingResponse: 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, + return RegisterPendingResponse( + message="OTP sent", + status="pending_verification", + email=req.email, ) async def mobile_login( @@ -70,13 +68,18 @@ 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() + with patch("app.deps.rate_limit.RedisClient.get_instance") as mock_get_instance: + mock_redis = AsyncMock() + mock_redis.incr.return_value = 1 + mock_get_instance.return_value = mock_redis + try: + yield TestClient(app) + finally: + app.dependency_overrides.clear() def _valid_payload() -> dict[str, object]: diff --git a/tests/unit/test_photo_approval_lifecycle.py b/tests/unit/test_photo_approval_lifecycle.py new file mode 100644 index 00000000..00577e6a --- /dev/null +++ b/tests/unit/test_photo_approval_lifecycle.py @@ -0,0 +1,166 @@ +import uuid +from unittest.mock import AsyncMock, MagicMock +import pytest + +from app.worker.photo_worker.main import PhotoWorker +from app.worker.photo_worker.schema.event import PhotoProcessEvent +from app.service.face_embedding import DetectedFace +from app.service.photo_approval import PhotoApprovalService + +@pytest.fixture +def mock_conn() -> AsyncMock: + return AsyncMock() + +@pytest.fixture +def mock_face_embedding_service() -> AsyncMock: + return AsyncMock() + +@pytest.fixture +def mock_single_face_service() -> AsyncMock: + return AsyncMock() + +@pytest.fixture +def mock_notification_service() -> AsyncMock: + return AsyncMock() + +@pytest.fixture +def mock_photo_face_querier() -> AsyncMock: + return AsyncMock() + +@pytest.fixture +def mock_photo_querier() -> AsyncMock: + return AsyncMock() + +@pytest.fixture +def mock_photo_approval_querier() -> AsyncMock: + return AsyncMock() + +@pytest.fixture +def mock_processing_job_querier() -> AsyncMock: + return AsyncMock() + +@pytest.fixture +def mock_staged_upload_storage_service() -> AsyncMock: + return AsyncMock() + + +@pytest.mark.asyncio +async def test_group_photo_pending_no_users( + mock_conn: AsyncMock, + mock_face_embedding_service: AsyncMock, + mock_single_face_service: AsyncMock, + mock_notification_service: AsyncMock, + mock_photo_face_querier: AsyncMock, + mock_photo_querier: AsyncMock, + mock_processing_job_querier: AsyncMock, +) -> None: + """ + Test: Group photo with no enrolled users -> photo becomes approved + public. + """ + worker = PhotoWorker( + conn=mock_conn, + face_embedding_service=mock_face_embedding_service, + single_face_service=mock_single_face_service, + user_notification_service=mock_notification_service, + photo_face_querier=mock_photo_face_querier, + photo_querier=mock_photo_querier, + processing_job_querier=mock_processing_job_querier, + ) + + photo_id = uuid.uuid4() + event = PhotoProcessEvent(photo_id=photo_id, image_ref="test.jpg") + + # 2 faces detected + faces = [ + DetectedFace(bbox=(0.0, 0.0, 10.0, 10.0), embedding=[0.1, 0.2]), + DetectedFace(bbox=(10.0, 10.0, 20.0, 20.0), embedding=[0.3, 0.4]), + ] + + # No face matches any user -> insert_photo_face_with_approval returns None + mock_photo_face_querier.insert_photo_face_with_approval.return_value = None + + await worker._handle_group_photo(event, faces) + + # Verify notifications were NOT sent + mock_notification_service.create_notification.assert_not_called() + + # Verify photo stays pending (not marked public or approved) + mock_photo_querier.update_photo_status.assert_not_called() + mock_photo_querier.update_photo_visibility.assert_not_called() + + +@pytest.mark.asyncio +async def test_group_photo_pending_with_enrolled_users( + mock_conn: AsyncMock, + mock_face_embedding_service: AsyncMock, + mock_single_face_service: AsyncMock, + mock_notification_service: AsyncMock, + mock_photo_face_querier: AsyncMock, + mock_photo_querier: AsyncMock, + mock_processing_job_querier: AsyncMock, +) -> None: + """ + Test: Group photo with at least one enrolled user -> approval records created, notifications sent, photo stays pending. + """ + worker = PhotoWorker( + conn=mock_conn, + face_embedding_service=mock_face_embedding_service, + single_face_service=mock_single_face_service, + user_notification_service=mock_notification_service, + photo_face_querier=mock_photo_face_querier, + photo_querier=mock_photo_querier, + processing_job_querier=mock_processing_job_querier, + ) + + photo_id = uuid.uuid4() + event = PhotoProcessEvent(photo_id=photo_id, image_ref="test.jpg") + + faces = [ + DetectedFace(bbox=(0.0, 0.0, 10.0, 10.0), embedding=[0.1, 0.2]), + ] + + # Mock DB returning an approval record + mock_approval = MagicMock() + mock_approval.user_id = uuid.uuid4() + mock_approval.photo_id = photo_id + mock_photo_face_querier.insert_photo_face_with_approval.return_value = mock_approval + + await worker._handle_group_photo(event, faces) + + # Verify notification WAS sent + mock_notification_service.create_notification.assert_called_once() + args, kwargs = mock_notification_service.create_notification.call_args + assert kwargs["user_id"] == mock_approval.user_id + + # Verify photo status is NOT updated to approved (stays pending) + mock_photo_querier.update_photo_status.assert_not_called() + mock_photo_querier.update_photo_visibility.assert_not_called() + + +@pytest.mark.asyncio +async def test_expire_stale_marks_photos_approved( + mock_photo_approval_querier: AsyncMock, + mock_photo_querier: AsyncMock, + mock_staged_upload_storage_service: AsyncMock, +) -> None: + """ + Test: After PHOTO_APPROVAL_TIMEOUT_DAYS days, expire_stale marks photos approved + """ + service = PhotoApprovalService( + photo_approval_querier=mock_photo_approval_querier, + photo_querier=mock_photo_querier, + storage_service=mock_staged_upload_storage_service, + ) + + from typing import Any, AsyncIterator + # Mock the generator for expire_stale_approvals + async def mock_generator(*args: Any, **kwargs: Any) -> AsyncIterator[uuid.UUID]: + yield uuid.uuid4() + yield uuid.uuid4() + + mock_photo_approval_querier.expire_stale_approvals = MagicMock(side_effect=mock_generator) + + count = await service.expire_stale(timeout_days=7) + + assert count == 2 + mock_photo_approval_querier.expire_stale_approvals.assert_called_once_with(timeout_days=7) diff --git a/tests/unit/test_photo_approval_service.py b/tests/unit/test_photo_approval_service.py new file mode 100644 index 00000000..d10c62bf --- /dev/null +++ b/tests/unit/test_photo_approval_service.py @@ -0,0 +1,333 @@ +""" +Unit tests for PhotoApprovalService. + +All queriers and the storage service are mocked — no live infrastructure. +Tests cover: approve/reject/pending decision logic, storage cleanup on rejection, +audit logging, and error resilience. +""" + +import uuid +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi.exceptions import HTTPException + +from app.service.photo_approval import PhotoApprovalService +from app.core.constant import AuditEventType + + +# --------------------------------------------------------------------------- +# Minimal stubs for DB models +# --------------------------------------------------------------------------- + + +def _make_approval(decision: str, user_id: uuid.UUID | None = None) -> MagicMock: + a = MagicMock() + a.decision = decision + a.user_id = user_id or uuid.uuid4() + return a + + +def _make_photo(storage_key: str = "photos/test.jpg") -> MagicMock: + p = MagicMock() + p.storage_key = storage_key + return p + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def approval_querier() -> AsyncMock: + from db.generated import photo_approvals as pa_queries + q = MagicMock(spec=pa_queries.AsyncQuerier) + q.update_photo_approval_decision = AsyncMock() + q.get_photo_approvals_by_photo_id = MagicMock() # async generator + return q + + +@pytest.fixture +def photo_querier() -> AsyncMock: + from db.generated import photos as photo_queries + q = MagicMock(spec=photo_queries.AsyncQuerier) + q.update_photo_status = AsyncMock(return_value=None) + q.get_photo_by_id = AsyncMock(return_value=_make_photo()) + return q + + +@pytest.fixture +def storage_service() -> AsyncMock: + from app.service.staged_upload_storage import StagedUploadStorageService + svc = MagicMock(spec=StagedUploadStorageService) + svc.delete_storage_key = AsyncMock() + return svc + + +@pytest.fixture +def audit_service() -> AsyncMock: + from app.service.audit import AuditService + svc = MagicMock(spec=AuditService) + svc.create_record = AsyncMock() + return svc + + +def _make_service( + approval_querier: AsyncMock, + photo_querier: AsyncMock, + storage_service: AsyncMock, + audit_service: AsyncMock | None = None, +) -> PhotoApprovalService: + return PhotoApprovalService( + photo_approval_querier=approval_querier, + photo_querier=photo_querier, + storage_service=storage_service, + audit_service=audit_service, + ) + + +def _mock_async_iter(items: list[object]): # type: ignore[type-arg] + """Return a MagicMock that behaves like an async for loop.""" + async def _gen(): # type: ignore[return] + for item in items: + yield item + return _gen() + + +# =========================================================================== +# 1. All decisions == "approved" → photo status becomes "approved" +# =========================================================================== + + +class TestApproveDecision: + @pytest.mark.asyncio + async def test_all_approved_sets_photo_status( + self, + approval_querier: AsyncMock, + photo_querier: AsyncMock, + storage_service: AsyncMock, + ) -> None: + photo_id = uuid.uuid4() + user_id = uuid.uuid4() + approvals = [_make_approval("approved"), _make_approval("approved")] + + approval_querier.update_photo_approval_decision.return_value = MagicMock() + approval_querier.get_photo_approvals_by_photo_id.return_value = _mock_async_iter(approvals) + + service = _make_service(approval_querier, photo_querier, storage_service) + result = await service.decide(photo_id=photo_id, user_id=user_id, decision="approved") + + assert result == "approved" + photo_querier.update_photo_status.assert_called_once_with( + id=photo_id, status="approved" + ) + + @pytest.mark.asyncio + async def test_all_approved_does_not_delete_storage( + self, + approval_querier: AsyncMock, + photo_querier: AsyncMock, + storage_service: AsyncMock, + ) -> None: + photo_id = uuid.uuid4() + user_id = uuid.uuid4() + approvals = [_make_approval("approved")] + + approval_querier.update_photo_approval_decision.return_value = MagicMock() + approval_querier.get_photo_approvals_by_photo_id.return_value = _mock_async_iter(approvals) + + service = _make_service(approval_querier, photo_querier, storage_service) + await service.decide(photo_id=photo_id, user_id=user_id, decision="approved") + + storage_service.delete_storage_key.assert_not_called() + + +# =========================================================================== +# 2. One rejection → photo status becomes "rejected" + storage deleted +# =========================================================================== + + +class TestRejectDecision: + @pytest.mark.asyncio + async def test_one_rejection_sets_photo_status_rejected( + self, + approval_querier: AsyncMock, + photo_querier: AsyncMock, + storage_service: AsyncMock, + ) -> None: + photo_id = uuid.uuid4() + user_id = uuid.uuid4() + approvals = [_make_approval("approved"), _make_approval("rejected")] + + approval_querier.update_photo_approval_decision.return_value = MagicMock() + approval_querier.get_photo_approvals_by_photo_id.return_value = _mock_async_iter(approvals) + + service = _make_service(approval_querier, photo_querier, storage_service) + result = await service.decide(photo_id=photo_id, user_id=user_id, decision="rejected") + + assert result == "rejected" + photo_querier.update_photo_status.assert_called_once_with( + id=photo_id, status="rejected" + ) + + @pytest.mark.asyncio + async def test_rejection_triggers_storage_deletion( + self, + approval_querier: AsyncMock, + photo_querier: AsyncMock, + storage_service: AsyncMock, + ) -> None: + photo_id = uuid.uuid4() + user_id = uuid.uuid4() + approvals = [_make_approval("rejected")] + storage_key = "photos/reject-me.jpg" + photo_querier.get_photo_by_id.return_value = _make_photo(storage_key=storage_key) + + approval_querier.update_photo_approval_decision.return_value = MagicMock() + approval_querier.get_photo_approvals_by_photo_id.return_value = _mock_async_iter(approvals) + + service = _make_service(approval_querier, photo_querier, storage_service) + await service.decide(photo_id=photo_id, user_id=user_id, decision="rejected") + + storage_service.delete_storage_key.assert_called_once_with(storage_key) + + @pytest.mark.asyncio + async def test_storage_deletion_failure_does_not_raise( + self, + approval_querier: AsyncMock, + photo_querier: AsyncMock, + storage_service: AsyncMock, + ) -> None: + """Storage errors must be swallowed — they should not surface to the client.""" + photo_id = uuid.uuid4() + user_id = uuid.uuid4() + approvals = [_make_approval("rejected")] + + approval_querier.update_photo_approval_decision.return_value = MagicMock() + approval_querier.get_photo_approvals_by_photo_id.return_value = _mock_async_iter(approvals) + storage_service.delete_storage_key.side_effect = Exception("MinIO unavailable") + + service = _make_service(approval_querier, photo_querier, storage_service) + # Must not raise despite MinIO being unavailable + result = await service.decide(photo_id=photo_id, user_id=user_id, decision="rejected") + assert result == "rejected" + + +# =========================================================================== +# 3. Pending approvals → returns "pending", no status update +# =========================================================================== + + +class TestPendingDecision: + @pytest.mark.asyncio + async def test_pending_approval_returns_pending( + self, + approval_querier: AsyncMock, + photo_querier: AsyncMock, + storage_service: AsyncMock, + ) -> None: + photo_id = uuid.uuid4() + user_id = uuid.uuid4() + approvals = [_make_approval("approved"), _make_approval("pending")] + + approval_querier.update_photo_approval_decision.return_value = MagicMock() + approval_querier.get_photo_approvals_by_photo_id.return_value = _mock_async_iter(approvals) + + service = _make_service(approval_querier, photo_querier, storage_service) + result = await service.decide(photo_id=photo_id, user_id=user_id, decision="approved") + + assert result == "pending" + + @pytest.mark.asyncio + async def test_pending_does_not_update_photo_status( + self, + approval_querier: AsyncMock, + photo_querier: AsyncMock, + storage_service: AsyncMock, + ) -> None: + photo_id = uuid.uuid4() + user_id = uuid.uuid4() + approvals = [_make_approval("pending"), _make_approval("pending")] + + approval_querier.update_photo_approval_decision.return_value = MagicMock() + approval_querier.get_photo_approvals_by_photo_id.return_value = _mock_async_iter(approvals) + + service = _make_service(approval_querier, photo_querier, storage_service) + await service.decide(photo_id=photo_id, user_id=user_id, decision="approved") + + photo_querier.update_photo_status.assert_not_called() + + +# =========================================================================== +# 4. Not found → raises 404 +# =========================================================================== + + +class TestNotFound: + @pytest.mark.asyncio + async def test_not_found_raises_404( + self, + approval_querier: AsyncMock, + photo_querier: AsyncMock, + storage_service: AsyncMock, + ) -> None: + photo_id = uuid.uuid4() + approval_querier.update_photo_approval_decision.return_value = None # not found + + service = _make_service(approval_querier, photo_querier, storage_service) + with pytest.raises(HTTPException) as exc_info: + await service.decide( + photo_id=photo_id, user_id=uuid.uuid4(), decision="approved" + ) + assert exc_info.value.status_code == 404 + + +# =========================================================================== +# 5. Audit logging +# =========================================================================== + + +class TestAuditLogging: + @pytest.mark.asyncio + async def test_audit_called_with_correct_event_type( + self, + approval_querier: AsyncMock, + photo_querier: AsyncMock, + storage_service: AsyncMock, + audit_service: AsyncMock, + ) -> None: + photo_id = uuid.uuid4() + user_id = uuid.uuid4() + approvals = [_make_approval("approved")] + + approval_querier.update_photo_approval_decision.return_value = MagicMock() + approval_querier.get_photo_approvals_by_photo_id.return_value = _mock_async_iter(approvals) + + service = _make_service(approval_querier, photo_querier, storage_service, audit_service) + await service.decide(photo_id=photo_id, user_id=user_id, decision="approved") + + audit_service.create_record.assert_called_once() + call_kwargs = audit_service.create_record.call_args.kwargs + assert call_kwargs["event_type"] == AuditEventType.PHOTO_APPROVAL_DECIDED + assert call_kwargs["user_id"] == user_id + assert str(photo_id) in str(call_kwargs["metadata"]) + + @pytest.mark.asyncio + async def test_no_audit_without_audit_service( + self, + approval_querier: AsyncMock, + photo_querier: AsyncMock, + storage_service: AsyncMock, + ) -> None: + """When audit_service=None, no error must be raised.""" + photo_id = uuid.uuid4() + approvals = [_make_approval("approved")] + + approval_querier.update_photo_approval_decision.return_value = MagicMock() + approval_querier.get_photo_approvals_by_photo_id.return_value = _mock_async_iter(approvals) + + # No audit_service passed → audit_service=None + service = _make_service(approval_querier, photo_querier, storage_service, audit_service=None) + await service.decide(photo_id=photo_id, user_id=uuid.uuid4(), decision="approved") + # no assertion needed — just must not raise diff --git a/uv.lock b/uv.lock index afaadba3..f7b00087 100644 --- a/uv.lock +++ b/uv.lock @@ -305,6 +305,7 @@ dependencies = [ { name = "bcrypt" }, { name = "cryptography" }, { name = "fastapi", extra = ["standard"] }, + { name = "filetype" }, { name = "firebase-admin" }, { name = "greenlet" }, { name = "insightface" }, @@ -315,6 +316,7 @@ dependencies = [ { name = "opencv-python" }, { name = "opencv-python-headless" }, { name = "passlib", extra = ["bcrypt"] }, + { name = "pillow-heif" }, { name = "psycopg" }, { name = "pydantic" }, { name = "pydantic-settings" }, @@ -324,6 +326,7 @@ dependencies = [ { name = "pywebpush" }, { name = "redis" }, { name = "setuptools" }, + { name = "sqlalchemy" }, ] [package.dev-dependencies] @@ -342,6 +345,7 @@ requires-dist = [ { name = "bcrypt", specifier = "==4.3.0" }, { name = "cryptography", specifier = ">=46.0.5" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.135.1" }, + { name = "filetype", specifier = ">=1.2.0" }, { name = "firebase-admin", specifier = ">=6.8.0" }, { name = "greenlet", specifier = ">=3.3.2" }, { name = "insightface", specifier = ">=0.7.3" }, @@ -352,6 +356,7 @@ requires-dist = [ { name = "opencv-python", specifier = ">=4.13.0.92" }, { name = "opencv-python-headless", specifier = ">=4.13.0.92" }, { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, + { name = "pillow-heif", specifier = ">=1.3.0" }, { name = "psycopg", specifier = ">=3.3.3" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "pydantic-settings", specifier = ">=2.13.1" }, @@ -361,6 +366,7 @@ requires-dist = [ { name = "pywebpush", specifier = ">=2.3.0" }, { name = "redis", specifier = ">=7.2.1" }, { name = "setuptools", specifier = ">=82.0.0" }, + { name = "sqlalchemy", specifier = ">=2.0.47" }, ] [package.metadata.requires-dev] @@ -919,6 +925,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/85/11/0aa8455af26f0ae89e42be67f3a874255ee5d7f0f026fc86e8d56f76b428/fastar-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:e59673307b6a08210987059a2bdea2614fe26e3335d0e5d1a3d95f49a05b1418", size = 460467, upload-time = "2025-11-26T02:36:07.978Z" }, ] +[[package]] +name = "filetype" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/29/745f7d30d47fe0f251d3ad3dc2978a23141917661998763bebb6da007eb1/filetype-1.2.0.tar.gz", hash = "sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb", size = 998020, upload-time = "2022-11-02T17:34:04.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/79/1b8fa1bb3568781e84c9200f951c735f3f157429f44be0495da55894d620/filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25", size = 19970, upload-time = "2022-11-02T17:34:01.425Z" }, +] + [[package]] name = "firebase-admin" version = "6.8.0" @@ -2318,6 +2333,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, ] +[[package]] +name = "pillow-heif" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/58/2df4fc42840633e01c97b75965cb1bc6e14425973b92382391650e97e4b7/pillow_heif-1.3.0.tar.gz", hash = "sha256:af8d2bda85e395677d5bb50d7bda3b5655c946cc95b913b5e7222fabacbb467f", size = 17133211, upload-time = "2026-02-27T12:21:36.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/f7/e0b13500470421536fdfe01acfc4c56daccd3d23655605aa04cfb30cc58c/pillow_heif-1.3.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:079abbcaeb42ef0849a33f35c1a96ccd431feb56b242a0d4f8435a1c8ca02c7d", size = 4667382, upload-time = "2026-02-27T12:20:45.149Z" }, + { url = "https://files.pythonhosted.org/packages/a6/fb/beb62f26231e7c76d235e993d18b4ddb3d2d427e93539b8e6eb8dc188fa7/pillow_heif-1.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76c33f80ec111492642b98309db98516a7fba9677dcda9ec5fe9111b7e38d720", size = 3392733, upload-time = "2026-02-27T12:20:46.606Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/3d86e237b3a20c909f62a50e8cb3492ed6206675136d1ebddb168920261b/pillow_heif-1.3.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33b838d06e2fd730f806af5a76bfc4cd3de9d146d88d37572e40f7a4c4ff8221", size = 5844247, upload-time = "2026-02-27T12:20:49.669Z" }, + { url = "https://files.pythonhosted.org/packages/58/2a/826faf3df8c9ef9a19dc96bdfff34cf76f8b025540d5f931903d2c64f25c/pillow_heif-1.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f92b387af891cf5d98f52e79eeaf51ee7955a54fe2deeec12bfb7519e41464b5", size = 5578692, upload-time = "2026-02-27T12:20:51.219Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4e/59f74fede18e3e06f98a448488df2fa4e5de1c159419d47ca345a51a0da0/pillow_heif-1.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f9f73246836f93f99343cbc3052b61d212d27e59ddf40262d494a1e3e54af31a", size = 6885928, upload-time = "2026-02-27T12:20:52.934Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e4/bce83d2f4d703f418d252b509a6c9d6de52dc7c4eedcf6a286f871dea824/pillow_heif-1.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:84c3816742c2e49176e651895e73c555b9c3b0f3561d60230242f3be0c9d272b", size = 6511151, upload-time = "2026-02-27T12:20:54.236Z" }, + { url = "https://files.pythonhosted.org/packages/41/c2/87d433a9681c79e0926d8a113ea153d592ec49d1d7aa7278ee798bc490f2/pillow_heif-1.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:5f0db0bf49162fb1d73d13340a9576b3a2805bde026a9a40038bcc1a0878d710", size = 5483578, upload-time = "2026-02-27T12:20:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/81/c3/9effa6ab5c2c2ffb80228143c578a9a2a8e2f059dd9d067ec6ff6f6c89db/pillow_heif-1.3.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:641c50a064aa9ad6626a6b2b914b65855202f937d573d53838e344feb2e8c6d1", size = 4667379, upload-time = "2026-02-27T12:20:57.561Z" }, + { url = "https://files.pythonhosted.org/packages/23/eb/b6b52e3655f366b95301f18aecd2d35487cace18d17134b80ad0f70cc1eb/pillow_heif-1.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9390dd7987887aa09779fbd88bbab715c732c9ad3a71d6707284035e3ca93379", size = 3392725, upload-time = "2026-02-27T12:20:59.52Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b3/b69610e9565fc8bcaf2303f412e857c0439d23cc18cf866c72a96ec6b2e6/pillow_heif-1.3.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e8444ccb330015e1db930207d269886e4b6c666121cd9e5fdad88735950b09f", size = 5844285, upload-time = "2026-02-27T12:21:00.771Z" }, + { url = "https://files.pythonhosted.org/packages/47/8c/be44f6dea425a9756ff418cb03f5ee75ed1c7dd1ff9bee1f3893b2b82da4/pillow_heif-1.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d30054ccc97ecbe5ee3fa486a505ccc33bfbb27f005ad624ddb4c17b80ddd57", size = 5578691, upload-time = "2026-02-27T12:21:02.193Z" }, + { url = "https://files.pythonhosted.org/packages/5c/74/e12d49346a39e2204b408a835b31b2fd9a5d51f97ce3a6015cf22ca09a54/pillow_heif-1.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dc1b9c9efdf8345d703118449ff69696d0827bdf28e3b52f82015f5714f7c23e", size = 6885923, upload-time = "2026-02-27T12:21:03.782Z" }, + { url = "https://files.pythonhosted.org/packages/80/a6/51c937a9433f5ae9c625b686ee338bdf0080a1661f7eb34daaf75424ee77/pillow_heif-1.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee26b2155721e7f5f7b10fa93ca2ad3be59547c5c5e5d9d50e6ea17531b81d60", size = 6511216, upload-time = "2026-02-27T12:21:05.134Z" }, + { url = "https://files.pythonhosted.org/packages/63/0a/bb8435e127f75b434166022471bbabf11c8c1fc3d48c8595fd6ab36c2785/pillow_heif-1.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:17ecbbadfe10ea12a65c1c12354dc1ed8ae1e5d1b7092ea753641b029f7d6f9e", size = 5483570, upload-time = "2026-02-27T12:21:06.566Z" }, + { url = "https://files.pythonhosted.org/packages/3e/17/aa056f8edb71396dd1131abcd0c6feab00097ceec89a12fc62d2dbc3ccf5/pillow_heif-1.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:8267a73d3b2d07a47a96428bd8cd4c406e1637a94f29d4c16ce08b31b8e50a07", size = 4667395, upload-time = "2026-02-27T12:21:08.16Z" }, + { url = "https://files.pythonhosted.org/packages/19/1f/da50ccd271a2878d17df359301dc2f7a79ec1cbb6e92c19ccc8c6219d497/pillow_heif-1.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:36bbea7679467caa3a154db11c04f1ca2fa8591e886f06f40f7831c14b58d771", size = 3392800, upload-time = "2026-02-27T12:21:09.668Z" }, + { url = "https://files.pythonhosted.org/packages/11/bc/1f89d927c1293cf283bc5d0ae6735d268d2de9749aa6fb94342ec838a457/pillow_heif-1.3.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea3a4b2de4b6c63407af72afdac901616807c6e6a030fe77851d227bca3727a", size = 5844547, upload-time = "2026-02-27T12:21:10.826Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/d781b23f8bff125c8dd8da63d928a35e38f2b727e89582a1fd323664e968/pillow_heif-1.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05149bd26b08dae5af7a389af6db13cef4f12c7871db73d84e40a1f3c83b0142", size = 5578827, upload-time = "2026-02-27T12:21:12.06Z" }, + { url = "https://files.pythonhosted.org/packages/2a/98/8dcdaafcf9bd8b26ed0569dc93653dc20a06faef7bfbdd4ba05c091c5b60/pillow_heif-1.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f8b7a50058fc3152f42b68aa2b30601249f61aa5c6c27876af076785c7051fd9", size = 6886088, upload-time = "2026-02-27T12:21:13.635Z" }, + { url = "https://files.pythonhosted.org/packages/99/26/93f3c8bfffb7e8fe0244bf86117235c49c23980e61320e7484c03ac836e2/pillow_heif-1.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:edb3ef437e8841475db14721f0529e600bb55c41b549ad1794a0831e28f33bac", size = 6511291, upload-time = "2026-02-27T12:21:15.354Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f9/a8c72619ec212eb2612730fa2b3068e2d4b59e0a0957c2e8418aa4cff59e/pillow_heif-1.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:bdd6695d5be0d98ae0e9a5f88fe26f1a6eca0a5b6d43d0a92a97f89fea5842f7", size = 5640949, upload-time = "2026-02-27T12:21:16.647Z" }, + { url = "https://files.pythonhosted.org/packages/b7/31/92ce30e1ada892e18a03042bd5a8414f655304a78a36790e657f14265fed/pillow_heif-1.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:65c5d05cb7f5e1eadbe9c605ae3a4dd3ef953adb33e7d809d5fb56f8a6753588", size = 4668365, upload-time = "2026-02-27T12:21:18.004Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2b/789fa3c82063a780e84de667771b8ec30bc328511855f15a83a3c77011ec/pillow_heif-1.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dc177fbdf598770cad4afa99c082a30b9d090e60c39656904338717803ae59b2", size = 3393554, upload-time = "2026-02-27T12:21:19.642Z" }, + { url = "https://files.pythonhosted.org/packages/5b/a4/4f8075f03c1d06d7afd674e263a3f57b7b24130c39b1544555b3b03ed369/pillow_heif-1.3.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71f88d180547bb5112b56310c8c5e338d8358320a402c80afabc6b2f39eadddb", size = 5849609, upload-time = "2026-02-27T12:21:20.953Z" }, + { url = "https://files.pythonhosted.org/packages/0d/08/e33a10bc84ade1b4ec56bdc765735bbfd452513e33537df68107edc0eb86/pillow_heif-1.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9acee893186bdde6140d30a7dc6d7c928e4ad3007989764f6e54a7a517faa332", size = 5582931, upload-time = "2026-02-27T12:21:22.571Z" }, + { url = "https://files.pythonhosted.org/packages/cb/45/6afc0f29701e0c9b911b33a35760ae6e2c581fc49b431dcce22ed18abfba/pillow_heif-1.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7cf893689132bec18f0c55a505da9ebf3a8feb33dd354fe2ac050f20f4f862e0", size = 6891268, upload-time = "2026-02-27T12:21:24.021Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0a/0d6a69f76f277692555d0e687dbf3e31d03cf76fffa3ced1fea51a18c481/pillow_heif-1.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:54404c9b6f0323114527579f54cc966b47206f99d943e47d73e1091ab0b9d2ba", size = 6515405, upload-time = "2026-02-27T12:21:25.336Z" }, + { url = "https://files.pythonhosted.org/packages/39/21/716856a36c1cc30a8f1354bf6423f251b1f50851af3e13b9cf084a13d2e3/pillow_heif-1.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:18c7c35a9d98ed9eaaf2db601ee43425ebccc698801df9c008aa04e00756a22e", size = 5641581, upload-time = "2026-02-27T12:21:26.642Z" }, +] + [[package]] name = "pluggy" version = "1.6.0"