174 lines
6.9 KiB
Python
174 lines
6.9 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: 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)}"}
|