""" Shared test setup. Every environment variable here must be set BEFORE `app.main` is imported, because app/infrastructure/settings.py reads the environment once at import time. pytest loads conftest.py before any test module, which is what makes this the right place for it - a per-module `os.environ` block cannot work, since the first test module to import the app fixes the settings for the whole process. python-dotenv does not override variables already present in the environment, so these win over a developer's real backend/.env. The suite is hermetic either way: no test touches the real database, S3 bucket, or auth secrets. """ from __future__ import annotations import base64 import hashlib import os import secrets import sys from pathlib import Path import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Sign-in passwords used across the suite. Fake, and only ever hashed below. TEST_ADMIN_PASSWORD = "test-admin-password" TEST_USER_PASSWORD = "test-user-password" TEST_API_KEY = "test-api-key-value-not-a-real-secret" def _hash(password: str, iterations: int = 20_000) -> str: """ Byte-compatible with security.hash_password, but at a far lower iteration count. 600k iterations is right for a real login; paying it on every test that signs in would add seconds to the suite for no extra coverage, and the encoded form carries its own count so verification still works. """ salt = secrets.token_bytes(16) digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iterations) return "$".join( ( "pbkdf2_sha256", str(iterations), base64.b64encode(salt).decode(), base64.b64encode(digest).decode(), ) ) # Not-secret-critical settings, so a fresh checkout without a .env still runs. os.environ.setdefault("USE_PGVECTOR", "true") os.environ.setdefault("DB_PASSWORD", "test-password-not-real") os.environ.setdefault("USE_S3", "false") os.environ.setdefault("USE_GOOGLE_CSE", "false") # Unconditional: the LLM description stage is on by default for every batch # and a developer machine often has `ollama serve` running, so without this a # pipeline test would make real, slow, non-deterministic model calls. Tests # that exercise the probe itself monkeypatch `ollama_service.USE_OLLAMA`. os.environ["USE_OLLAMA"] = "false" # Unconditional, NOT setdefault. The suite pins brand-name behaviour all over # the place ("any Nestle chocolates?", the suggest ranking fixtures), and a # developer's backend/.env now carries a real ACTIVE_BRANDS value. Letting that # leak in would make those tests pass or fail depending on whose machine ran # them - the same trap AUTH_ALLOW_ANY_LOGIN sprang before it was pinned here. # # Blank means "no brand filtering", so every existing test sees the historical # behaviour. The filtering itself is covered by tests/test_active_brands.py, # which sets the value explicitly and clears the parsed cache. os.environ["ACTIVE_BRANDS"] = "" # Unconditional, and OFF - the opposite of the production default. # # Finishing an ingestion batch queues nutrition enrichment on a background # thread, and that thread calls out to a database and to Open Food Facts. Any # test that runs `run_batch` would start it, so a `pytest` run reached for the # host in the developer's backend/.env - which on this project is PRODUCTION. # It got no further than a failed password, which is not a margin worth # relying on. # # The behaviour itself is covered by tests/test_auto_enrich_on_upload.py, which # turns it on explicitly, the same arrangement ACTIVE_BRANDS has above. os.environ["AUTO_ENRICH_ON_UPLOAD"] = "false" # Same arrangement for the image-vector worker: every call to # `upsert_brand_products` would otherwise start a thread that opens a # database connection and downloads product images. Off here; the hook and # the worker are covered explicitly in tests/test_image_vector.py. os.environ["ENABLE_IMAGE_VECTORS"] = "false" # And for server-side OCR: no test may import onnxruntime or load the 30MB # PP-OCR models. tests/test_ocr_service.py drives the service with a fake # engine and flips the flag on itself. os.environ["ENABLE_SERVER_OCR"] = "false" # Auth is set unconditionally (not setdefault): the suite asserts on the real # guards, so it must never inherit a developer's AUTH_ENABLED=false. os.environ["AUTH_ENABLED"] = "true" os.environ["AUTH_ALLOW_ANY_LOGIN"] = "false" os.environ["AUTH_SECRET_KEY"] = "test-secret-key-not-for-production-use-at-all" os.environ["AUTH_ADMIN_USERNAME"] = "admin" os.environ["AUTH_ADMIN_PASSWORD_HASH"] = _hash(TEST_ADMIN_PASSWORD) os.environ["AUTH_USER_USERNAME"] = "user" os.environ["AUTH_USER_PASSWORD_HASH"] = _hash(TEST_USER_PASSWORD) os.environ["API_KEYS"] = f"test-machine:user:{TEST_API_KEY}" # Low enough that the lockout test does not need 10 rounds of PBKDF2. os.environ["AUTH_MAX_LOGIN_ATTEMPTS"] = "3" os.environ["AUTH_LOCKOUT_SECONDS"] = "60" from fastapi.testclient import TestClient # noqa: E402 from app.main import app # noqa: E402 @pytest.fixture(scope="session") def client() -> TestClient: return TestClient(app) @pytest.fixture(autouse=True) def _reset_login_throttle(): """ Clear the failed-login counters between tests. The throttle is deliberately process-global state, so without this a test that exercises bad passwords would leak a lockout into whichever test happened to run next - a failure that moves when tests are reordered. """ from app.api.routers import auth as auth_router with auth_router._failures_lock: auth_router._failures.clear() yield with auth_router._failures_lock: auth_router._failures.clear() @pytest.fixture def review_inbox_mode(monkeypatch): """Pin POST /api/uploads/catalog to the review-inbox path. `UPLOAD_AUTORUN` ships true, so an upload now runs the pipeline on arrival. The inbox is still a supported mode - it is exactly what setting the flag false turns back on - and the modules covering it declare this fixture autouse, so their subject is stated rather than inherited from whatever the default happens to be on the day. Patched on the ROUTER module, not on settings: the handler reads the name out of its own globals at call time, which is the idiom batch_common's docstring describes for the BATCH_* limits. """ from app.api.routers import uploads monkeypatch.setattr(uploads, "UPLOAD_AUTORUN", False) def _token(client: TestClient, username: str, password: str) -> str: resp = client.post("/api/auth/login", json={"username": username, "password": password}) assert resp.status_code == 200, resp.text return resp.json()["access_token"] @pytest.fixture def admin_headers(client: TestClient) -> dict: return {"Authorization": f"Bearer {_token(client, 'admin', TEST_ADMIN_PASSWORD)}"} @pytest.fixture def user_headers(client: TestClient) -> dict: return {"Authorization": f"Bearer {_token(client, 'user', TEST_USER_PASSWORD)}"}