Files
catalogue_backend/tests/conftest.py

125 lines
4.6 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"] = ""
# 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_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()
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)}"}