""" 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, 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"] = "" # 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)}"}