Files
catalogue_backend/tests/conftest.py
2026-09-04 12:06:40 +05:30

158 lines
6.0 KiB
Python

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