Updated backend

This commit is contained in:
sriram
2026-08-12 16:37:28 +05:30
parent eb3567df62
commit ac8cfacbc9
21 changed files with 1496 additions and 155 deletions

113
tests/conftest.py Normal file
View File

@@ -0,0 +1,113 @@
"""
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)}"}

View File

@@ -13,29 +13,13 @@ automatically unless `RUN_INTEGRATION_TESTS=1` is set - see that file).
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
# Make sure required-but-not-secret-critical settings have *something* set
# before app.main is imported, so settings.py's _require() checks don't
# blow up the test run when no real .env is present.
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")
import pytest
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
# Environment setup (settings that must exist before app.main is imported,
# including the auth secrets) lives in tests/conftest.py, which pytest loads
# first. The `client`, `admin_headers` and `user_headers` fixtures come from
# there too.
def test_root() -> None:
def test_root(client) -> None:
resp = client.get("/")
assert resp.status_code == 200
if "text/html" in resp.headers.get("content-type", ""):
@@ -44,7 +28,7 @@ def test_root() -> None:
assert "service" in resp.json()
def test_health_degrades_gracefully_without_dependencies() -> None:
def test_health_degrades_gracefully_without_dependencies(client) -> None:
resp = client.get("/api/health")
assert resp.status_code == 200
body = resp.json()
@@ -53,13 +37,13 @@ def test_health_degrades_gracefully_without_dependencies() -> None:
assert isinstance(body["ollama"], bool)
def test_brands_returns_empty_list_without_database() -> None:
def test_brands_returns_empty_list_without_database(client) -> None:
resp = client.get("/api/brands")
assert resp.status_code == 200
assert resp.json() == {"brands": []}
def test_brand_products_returns_empty_without_database() -> None:
def test_brand_products_returns_empty_without_database(client) -> None:
resp = client.get("/api/brands/Parle/products")
assert resp.status_code == 200
body = resp.json()
@@ -68,30 +52,38 @@ def test_brand_products_returns_empty_without_database() -> None:
assert body["total"] == 0
def test_product_detail_404_when_missing() -> None:
def test_product_detail_404_when_missing(client) -> None:
resp = client.get("/api/brands/Parle/products/does-not-exist")
assert resp.status_code == 404
def test_chat_validates_empty_query() -> None:
resp = client.post("/api/chat", json={"query": ""})
# These now sign in first. The guard runs before body validation, so an
# unauthenticated call gets 401 and never reaches the schema check these
# assertions are actually about. tests/test_auth.py covers the guards
# themselves.
def test_chat_validates_empty_query(client, user_headers) -> None:
resp = client.post("/api/chat", json={"query": ""}, headers=user_headers)
assert resp.status_code == 422 # min_length=1 violated
def test_chat_rejects_too_many_top_k() -> None:
resp = client.post("/api/chat", json={"query": "snacks", "top_k": 999})
def test_chat_rejects_too_many_top_k(client, user_headers) -> None:
resp = client.post("/api/chat", json={"query": "snacks", "top_k": 999}, headers=user_headers)
assert resp.status_code == 422 # le=15 violated
def test_catalog_generate_returns_job_id() -> None:
resp = client.post("/api/catalog/generate", json={"brand": "TestBrand", "max_products": 1})
def test_catalog_generate_returns_job_id(client, admin_headers) -> None:
resp = client.post(
"/api/catalog/generate",
json={"brand": "TestBrand", "max_products": 1},
headers=admin_headers,
)
assert resp.status_code == 202
body = resp.json()
assert body["brand"] == "TestBrand"
assert body["status"] in {"pending", "running", "done", "failed"}
def test_openapi_schema_lists_all_routers() -> None:
def test_openapi_schema_lists_all_routers(client) -> None:
resp = client.get("/openapi.json")
assert resp.status_code == 200
paths = resp.json()["paths"]

328
tests/test_auth.py Normal file
View File

@@ -0,0 +1,328 @@
"""
Tests for authentication and the endpoint guards.
The behaviours asserted here are the ones the previous implementation got
wrong, so each has a comment saying what it prevents rather than just what it
checks. They need no database or Ollama: a request that is rejected at the
guard never reaches a service.
"""
from __future__ import annotations
import time
import jwt
import pytest
from tests.conftest import TEST_ADMIN_PASSWORD, TEST_API_KEY, TEST_USER_PASSWORD
# Every write/compute endpoint, with a request body valid enough that a 422
# would prove the guard let the request through to validation.
WRITE_ENDPOINTS = [
("/api/catalog/generate", {"json": {"brand": "X", "max_products": 1}}),
("/api/chat", {"json": {"query": "snacks"}}),
("/api/system/init", {}),
("/api/admin/store-intelligence/seed", {"json": {}}),
("/api/admin/store-intelligence/train", {"json": {}}),
("/api/admin/nutrition-intelligence/enrich", {"json": {}}),
("/api/admin/nutrition-intelligence/train", {"json": {}}),
("/api/admin/training/allocate-discounts", {"json": {}}),
("/api/admin/training/upload-dataset", {"files": {"file": ("a.csv", b"x")}}),
("/api/user/products/add", {"json": {}}),
("/api/user/products/batch-add", {"json": {}}),
("/api/user/products/upload-file", {"files": {"file": ("a.csv", b"x")}}),
("/api/upload/stores", {"files": {"file": ("a.csv", b"x")}}),
("/api/upload/stores/upload", {"files": {"file": ("a.csv", b"x")}}),
("/api/upload/analytics", {"files": {"file": ("a.csv", b"x")}}),
("/api/upload/analytics/upload", {"files": {"file": ("a.csv", b"x")}}),
("/api/upload/nutrition", {"files": {"file": ("a.csv", b"x")}}),
("/api/upload/nutrition/upload", {"files": {"file": ("a.csv", b"x")}}),
]
ADMIN_ONLY_ENDPOINTS = [
("/api/catalog/generate", {"brand": "X", "max_products": 1}),
("/api/system/init", None),
("/api/admin/store-intelligence/seed", {}),
("/api/admin/store-intelligence/train", {}),
("/api/admin/nutrition-intelligence/enrich", {}),
("/api/admin/nutrition-intelligence/train", {}),
]
# ---------------------------------------------------------------------------
# Guards
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("path,kwargs", WRITE_ENDPOINTS)
def test_write_endpoints_reject_anonymous_callers(client, path, kwargs):
"""
The whole point of this change. Every one of these was reachable by anyone
who could resolve the hostname, including catalog generation, ML training,
the upload endpoints, and unmetered Ollama access.
"""
resp = client.post(path, **kwargs)
assert resp.status_code == 401, f"{path} answered {resp.status_code}, expected 401"
@pytest.mark.parametrize("path,body", ADMIN_ONLY_ENDPOINTS)
def test_admin_endpoints_reject_the_user_role(client, user_headers, path, body):
"""403, not 401: the caller is authenticated, just not allowed."""
resp = client.post(path, json=body, headers=user_headers)
assert resp.status_code == 403, f"{path} answered {resp.status_code}, expected 403"
def test_admin_passes_an_admin_only_endpoint(client, admin_headers):
resp = client.post(
"/api/catalog/generate",
json={"brand": "TestBrand", "max_products": 1},
headers=admin_headers,
)
assert resp.status_code == 202
def test_admin_is_a_superuser_over_user_role_permissions(client, admin_headers):
"""
`upload_store_inventory` is granted to the user role only, but admin must
still pass - Principal.has_permission short-circuits on role == 'admin'.
Not 403 is the assertion; the 400 that follows is the handler rejecting a
bogus file, which proves the request got past the guard.
"""
resp = client.post(
"/api/upload/stores", files={"file": ("a.txt", b"x")}, headers=admin_headers
)
assert resp.status_code != 403
def test_user_reaches_its_own_permissioned_endpoint(client, user_headers):
resp = client.post(
"/api/upload/stores", files={"file": ("a.txt", b"x")}, headers=user_headers
)
assert resp.status_code != 403
@pytest.mark.parametrize("path", ["/api/health", "/api/brands", "/api/auth/roles"])
def test_read_endpoints_stay_public(client, path):
"""Guarding writes must not have closed off the catalog the app browses."""
assert client.get(path).status_code == 200
# ---------------------------------------------------------------------------
# Login
# ---------------------------------------------------------------------------
def test_login_succeeds_and_returns_a_token(client):
resp = client.post(
"/api/auth/login", json={"username": "admin", "password": TEST_ADMIN_PASSWORD}
)
assert resp.status_code == 200
body = resp.json()
assert body["token_type"] == "bearer"
assert body["access_token"]
assert body["expires_in"] > 0
assert body["user"]["role"] == "admin"
def test_login_is_case_insensitive_on_username_only(client):
"""Usernames are normalised; passwords are not. The old version lowercased
the password before comparing, which quietly shrank the keyspace."""
assert client.post(
"/api/auth/login", json={"username": "ADMIN", "password": TEST_ADMIN_PASSWORD}
).status_code == 200
assert client.post(
"/api/auth/login", json={"username": "admin", "password": TEST_ADMIN_PASSWORD.upper()}
).status_code == 401
def test_login_rejects_an_empty_password(client):
"""The old implementation treated an empty password as valid for any known
username (`if pwd in passwords or pwd == ""`)."""
resp = client.post("/api/auth/login", json={"username": "admin", "password": ""})
assert resp.status_code == 422 # min_length=1 on the schema
def test_login_rejects_an_unknown_username(client):
"""The old fallback granted a profile to ANY username, and `admin` to any
username that also asked for role='admin'."""
resp = client.post(
"/api/auth/login", json={"username": "somebody-new", "password": "whatever"}
)
assert resp.status_code == 401
def test_login_cannot_be_talked_into_a_role(client):
"""A `role` field in the body is not part of the schema and must not be
honoured - the role comes from the account the password belongs to."""
resp = client.post(
"/api/auth/login",
json={"username": "user", "password": TEST_USER_PASSWORD, "role": "admin"},
)
assert resp.status_code == 200
assert resp.json()["user"]["role"] == "user"
def test_failed_logins_are_throttled(client):
"""An exposed login endpoint must not be an unlimited password oracle."""
for _ in range(3): # AUTH_MAX_LOGIN_ATTEMPTS in conftest
assert client.post(
"/api/auth/login", json={"username": "admin", "password": "wrong"}
).status_code == 401
resp = client.post("/api/auth/login", json={"username": "admin", "password": "wrong"})
assert resp.status_code == 429
assert "Retry-After" in resp.headers
# The lockout must also hold against the CORRECT password, or it is trivial
# to sidestep by guessing until you land on it.
assert client.post(
"/api/auth/login", json={"username": "admin", "password": TEST_ADMIN_PASSWORD}
).status_code == 429
def test_roles_endpoint_no_longer_publishes_working_passwords(client):
"""It used to return demo_username/demo_password for both accounts."""
body = client.get("/api/auth/roles").json()
assert "demo_password" not in client.get("/api/auth/roles").text
assert {r["id"] for r in body["roles"]} == {"admin", "user"}
# ---------------------------------------------------------------------------
# Tokens
# ---------------------------------------------------------------------------
def test_me_returns_the_signed_in_profile(client, admin_headers):
resp = client.get("/api/auth/me", headers=admin_headers)
assert resp.status_code == 200
assert resp.json()["username"] == "admin"
def test_me_requires_a_token(client):
assert client.get("/api/auth/me").status_code == 401
def test_expired_token_is_rejected(client):
from app.infrastructure.security import create_access_token
token, _ = create_access_token("admin", "admin", [], ttl_minutes=-1)
resp = client.get("/api/auth/me", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 401
assert "expired" in resp.json()["detail"].lower()
def test_unsigned_alg_none_token_is_rejected(client):
"""
The classic JWT bypass: present a token with `alg: none` and no signature.
decode_access_token pins algorithms to ["HS256"] instead of trusting the
header, which is what closes it.
"""
forged = jwt.encode(
{
"sub": "admin",
"role": "admin",
"perms": [],
"iss": "brand-catalog-rag",
"iat": int(time.time()),
"exp": int(time.time()) + 3600,
},
key="",
algorithm="none",
)
assert client.get(
"/api/auth/me", headers={"Authorization": f"Bearer {forged}"}
).status_code == 401
def test_token_signed_with_the_wrong_key_is_rejected(client):
forged = jwt.encode(
{
"sub": "admin",
"role": "admin",
"perms": [],
"iss": "brand-catalog-rag",
"iat": int(time.time()),
"exp": int(time.time()) + 3600,
},
# At least 32 bytes: PyJWT warns about shorter HMAC keys (RFC 7518
# §3.2), and a warning raised from a test asserting a rejection is
# noise that hides real ones.
key="a-wrong-key-that-is-long-enough-to-not-warn",
algorithm="HS256",
)
assert client.get(
"/api/auth/me", headers={"Authorization": f"Bearer {forged}"}
).status_code == 401
def test_token_naming_an_unknown_role_is_rejected(client):
"""A validly signed token still cannot invent a role."""
from app.infrastructure.settings import AUTH_SECRET_KEY
token = jwt.encode(
{
"sub": "admin",
"role": "superuser",
"perms": ["everything"],
"iss": "brand-catalog-rag",
"iat": int(time.time()),
"exp": int(time.time()) + 3600,
},
key=AUTH_SECRET_KEY,
algorithm="HS256",
)
assert client.get(
"/api/auth/me", headers={"Authorization": f"Bearer {token}"}
).status_code == 401
def test_garbage_bearer_token_is_rejected(client):
assert client.get(
"/api/auth/me", headers={"Authorization": "Bearer not-even-a-jwt"}
).status_code == 401
# ---------------------------------------------------------------------------
# API keys (machine consumers)
# ---------------------------------------------------------------------------
def test_valid_api_key_authenticates(client):
resp = client.get("/api/auth/me", headers={"X-API-Key": TEST_API_KEY})
assert resp.status_code == 200
assert resp.json()["role"] == "user"
def test_invalid_api_key_is_rejected(client):
assert client.get(
"/api/auth/me", headers={"X-API-Key": "wrong-key"}
).status_code == 401
def test_api_key_is_bound_to_its_configured_role(client):
"""The test key is a `user`, so admin-only endpoints must still refuse it."""
resp = client.post(
"/api/catalog/generate",
json={"brand": "X", "max_products": 1},
headers={"X-API-Key": TEST_API_KEY},
)
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# Password hashing
# ---------------------------------------------------------------------------
def test_password_round_trip():
from app.infrastructure.security import hash_password, verify_password
encoded = hash_password("correct horse battery staple", iterations=1000)
assert verify_password("correct horse battery staple", encoded)
assert not verify_password("wrong", encoded)
def test_hashes_are_salted():
"""Two hashes of the same password must differ, or the digest leaks that
two accounts share a password."""
from app.infrastructure.security import hash_password
assert hash_password("same", iterations=1000) != hash_password("same", iterations=1000)
@pytest.mark.parametrize("bad", ["", "not-a-hash", "pbkdf2_sha256$notanint$a$b", "a$b$c$d"])
def test_malformed_hash_fails_closed(bad):
"""A typo in AUTH_ADMIN_PASSWORD_HASH must fail the login, not 500 the
endpoint and hand the caller a stack trace of the credential store."""
from app.infrastructure.security import verify_password
assert verify_password("anything", bad) is False