from __future__ import annotations import asyncio import uuid from datetime import timedelta import pytest from fastapi import HTTPException from fastapi.testclient import TestClient from business_models import AuthSessionPayload, UserRecord, UserRole, UserSessionRecord, utc_now pytestmark = pytest.mark.backend_unit def _auth_payload( *, user_id: str | None = None, token: str = "test-token", role: UserRole = UserRole.USER ) -> AuthSessionPayload: uid = user_id or str(uuid.uuid4()) session_id = str(uuid.uuid4()) user = UserRecord( id=uid, kind="guest", role=role, status="active", ) session = UserSessionRecord( id=session_id, user_id=uid, anonymous_device_id=str(uuid.uuid4()), status="active", expires_at=utc_now() + timedelta(days=30), user=user, ) return AuthSessionPayload(token=token, session=session, user=user) class FakeAuthRepository: cookie_name = "metarec_session" def __init__(self, payload: AuthSessionPayload | None = None): self.payload = payload or _auth_payload() self.revoked: list[str | None] = [] async def get_or_create_guest(self, *, device_id: str, user_agent: str | None = None, ttl_days: int = 30): return self.payload async def register( self, *, email: str, password: str, display_name: str | None = None, existing_guest_user_id: str | None = None, ttl_days: int = 30, ): self.existing_guest_user_id = existing_guest_user_id return self.payload async def login(self, *, email: str, password: str, ttl_days: int = 30): return self.payload async def session_from_token(self, token: str | None): return self.payload if token == self.payload.token else None async def revoke_token(self, token: str | None): self.revoked.append(token) def test_guest_login_sets_cookie_and_session_endpoint_uses_it(monkeypatch): import main payload = _auth_payload(token="guest-token") fake_auth = FakeAuthRepository(payload) monkeypatch.setattr(main, "auth_repository", fake_auth) with TestClient(main.app) as client: login_response = client.post("/api/auth/guest", json={"device_id": "browser-device-id"}) assert login_response.status_code == 200 assert client.cookies.get(main.AUTH_COOKIE_NAME) == "guest-token" assert login_response.json()["user"]["id"] == payload.user.id session_response = client.get("/api/auth/session") assert session_response.status_code == 200 assert session_response.json()["session"]["user_id"] == payload.user.id def test_process_rejects_user_id_outside_current_session(monkeypatch): import main payload = _auth_payload(token="session-token") fake_auth = FakeAuthRepository(payload) monkeypatch.setattr(main, "auth_repository", fake_auth) async def fail_if_called(*args, **kwargs): raise AssertionError("service should not run for a mismatched authenticated user") monkeypatch.setattr(main.metarec_service, "handle_user_request_async", fail_if_called) with TestClient(main.app) as client: client.cookies.set(main.AUTH_COOKIE_NAME, "session-token") response = client.post( "/api/process", json={"query": "find dinner", "user_id": str(uuid.uuid4())}, ) assert response.status_code == 403 assert response.json()["detail"] == "user_id does not match authenticated session" def test_process_uses_session_user_when_request_user_id_is_default(monkeypatch): import main payload = _auth_payload(token="session-token") fake_auth = FakeAuthRepository(payload) monkeypatch.setattr(main, "auth_repository", fake_auth) captured: dict[str, str] = {} async def fake_handle(query, user_id, *args, **kwargs): captured["query"] = query captured["user_id"] = user_id return { "type": "llm_reply", "llm_reply": "hello", "metadata": {"source": "test"}, } monkeypatch.setattr(main.metarec_service, "handle_user_request_async", fake_handle) with TestClient(main.app) as client: client.cookies.set(main.AUTH_COOKIE_NAME, "session-token") response = client.post("/api/process", json={"query": "hello", "user_id": "default"}) assert response.status_code == 200 assert response.json()["llm_reply"] == "hello" assert captured == {"query": "hello", "user_id": payload.user.id} def test_register_upgrades_current_guest_session(monkeypatch): import main payload = _auth_payload(token="registered-token") guest_payload = _auth_payload(user_id=payload.user.id, token="guest-token") fake_auth = FakeAuthRepository(payload) async def fake_session_from_token(token: str | None): return guest_payload if token == "guest-token" else None fake_auth.session_from_token = fake_session_from_token monkeypatch.setattr(main, "auth_repository", fake_auth) with TestClient(main.app) as client: client.cookies.set(main.AUTH_COOKIE_NAME, "guest-token") response = client.post( "/api/auth/register", json={"email": "user@example.com", "password": "password123"}, ) assert response.status_code == 200 assert fake_auth.existing_guest_user_id == guest_payload.user.id def test_require_admin_session_enforces_admin_role(monkeypatch): import main admin_payload = _auth_payload(role=UserRole.ADMIN) user_payload = _auth_payload(role=UserRole.USER) async def as_admin(_request): return admin_payload async def as_user(_request): return user_payload async def as_anonymous(_request): return None # Admin session is allowed through. monkeypatch.setattr(main, "get_optional_auth_session", as_admin) assert asyncio.run(main.require_admin_session(object())) is admin_payload # Authenticated non-admin is forbidden (403). monkeypatch.setattr(main, "get_optional_auth_session", as_user) with pytest.raises(HTTPException) as forbidden: asyncio.run(main.require_admin_session(object())) assert forbidden.value.status_code == 403 # Unauthenticated is rejected (401). monkeypatch.setattr(main, "get_optional_auth_session", as_anonymous) with pytest.raises(HTTPException) as unauthorized: asyncio.run(main.require_admin_session(object())) assert unauthorized.value.status_code == 401 def test_password_hash_accepts_common_registered_password_length(): from business_repositories import pwd_context password = "password" hashed = pwd_context.hash(password) assert hashed assert pwd_context.verify(password, hashed)