114 lines
3.9 KiB
Python
114 lines
3.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")
|
|
|
|
# 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)}"}
|