Verified catalogue of mobiles and laptops sold in India, collected from real retail listings (FastAPI backend, React frontend, Postgres/pgvector). - REST API under /api/elec (read-only catalogue; admin endpoints need login) - MCP server (FastMCP) at /mcp/ with list_categories, search_products, get_product and price_history tools - Real ratings and reviews read from product pages and search results - Production Dockerfile (requirements-api.txt, no PyTorch) and .env.production.example; remote database only via an explicit ELEC_ALLOW_REMOTE_DB host/name allowlist - docs/API.md: endpoint and MCP reference with live examples Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
406 lines
16 KiB
Python
406 lines
16 KiB
Python
"""
|
|
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/elec/admin/runs", {"json": {"category": "mobiles"}}),
|
|
("/api/elec/admin/review/1", {"json": {"approve": True}}),
|
|
("/api/elec/admin/sites/croma.com/probe", {}),
|
|
]
|
|
|
|
ADMIN_ONLY_ENDPOINTS = [
|
|
("/api/elec/admin/runs", {"category": "mobiles"}),
|
|
("/api/elec/admin/review/1", {"approve": True}),
|
|
("/api/elec/admin/sites/croma.com/probe", None),
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Guards
|
|
# ---------------------------------------------------------------------------
|
|
@pytest.mark.parametrize("path,kwargs", WRITE_ENDPOINTS)
|
|
def test_write_endpoints_reject_anonymous_callers(client, path, kwargs):
|
|
"""Starting a crawl or probing a site must never be open to anyone who can
|
|
reach the port."""
|
|
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):
|
|
"""400 (a brand outside the allow-list) proves the guard let admin through."""
|
|
resp = client.post("/api/elec/admin/runs", json={"category": "mobiles", "brands": ["nokia"]},
|
|
headers=admin_headers)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
@pytest.mark.parametrize("path", ["/api/health", "/api/auth/roles", "/openapi.json"])
|
|
def test_read_endpoints_stay_public(client, path):
|
|
"""Guarding writes must not have closed off what 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/elec/admin/runs",
|
|
json={"category": "mobiles"},
|
|
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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Why a sign-in failed
|
|
# ---------------------------------------------------------------------------
|
|
# The caller is told the same thing whatever went wrong - that is deliberate and
|
|
# is pinned below. The operator is not: an account whose configured hash is
|
|
# stale or corrupt needs a different repair from a mistyped password, and
|
|
# collapsing the two is how a production sign-in outage stayed unexplained for a
|
|
# day. These tests hold both halves at once: three reasons in the log, one
|
|
# response on the wire.
|
|
#
|
|
# Throttle budget: conftest sets AUTH_MAX_LOGIN_ATTEMPTS=3 per (username, IP),
|
|
# so each test below keeps `admin` to at most two attempts. Exceeding it turns a
|
|
# 401 assertion into a 429 and reads like a code bug.
|
|
import logging
|
|
|
|
from app.api.routers import auth as auth_router
|
|
from app.infrastructure.security import hash_is_wellformed
|
|
|
|
_AUTH_LOGGER = "app.api.routers.auth"
|
|
|
|
|
|
def test_an_unknown_username_is_logged_as_such(client, caplog):
|
|
with caplog.at_level(logging.WARNING, logger=_AUTH_LOGGER):
|
|
assert client.post(
|
|
"/api/auth/login", json={"username": "nobody", "password": "whatever"}
|
|
).status_code == 401
|
|
|
|
assert "reason=unknown-username" in caplog.text
|
|
# Names the setting to look at, since that is the actual repair.
|
|
assert "AUTH_ADMIN_USERNAME" in caplog.text
|
|
|
|
|
|
def test_a_wrong_password_is_logged_as_such(client, caplog):
|
|
with caplog.at_level(logging.WARNING, logger=_AUTH_LOGGER):
|
|
assert client.post(
|
|
"/api/auth/login", json={"username": "admin", "password": "not-the-password"}
|
|
).status_code == 401
|
|
|
|
assert "reason=bad-password" in caplog.text
|
|
|
|
|
|
def test_a_malformed_configured_hash_is_logged_as_an_error(client, caplog, monkeypatch):
|
|
"""Not a WARNING: no password can match an unparseable digest, so this is a
|
|
broken deployment rather than a failed guess. `_accounts()` re-reads this
|
|
module global on every call, which is what makes it patchable here."""
|
|
monkeypatch.setattr(auth_router, "AUTH_ADMIN_PASSWORD_HASH", "not-a-hash")
|
|
|
|
with caplog.at_level(logging.WARNING, logger=_AUTH_LOGGER):
|
|
assert client.post(
|
|
"/api/auth/login", json={"username": "admin", "password": TEST_ADMIN_PASSWORD}
|
|
).status_code == 401
|
|
|
|
assert "reason=malformed-hash" in caplog.text
|
|
assert any(
|
|
r.levelno == logging.ERROR and "malformed-hash" in r.getMessage()
|
|
for r in caplog.records
|
|
)
|
|
|
|
|
|
def test_every_failure_reason_returns_an_identical_response(client, monkeypatch):
|
|
"""The log distinguishes them; the wire must not. If any of these three
|
|
responses differed - by status, body, or headers - the endpoint would
|
|
enumerate valid usernames and report its own misconfiguration to anyone."""
|
|
unknown = client.post(
|
|
"/api/auth/login", json={"username": "nobody", "password": "x"}
|
|
)
|
|
wrong = client.post(
|
|
"/api/auth/login", json={"username": "admin", "password": "not-the-password"}
|
|
)
|
|
monkeypatch.setattr(auth_router, "AUTH_ADMIN_PASSWORD_HASH", "not-a-hash")
|
|
broken = client.post(
|
|
"/api/auth/login", json={"username": "admin", "password": TEST_ADMIN_PASSWORD}
|
|
)
|
|
|
|
responses = [unknown, wrong, broken]
|
|
assert {r.status_code for r in responses} == {401}
|
|
assert len({r.text for r in responses}) == 1
|
|
assert all(r.json() == {"detail": "Invalid username or password."} for r in responses)
|
|
for r in responses:
|
|
joined = r.text + " ".join(f"{k}:{v}" for k, v in r.headers.items())
|
|
for leak in ("unknown-username", "bad-password", "malformed-hash", "reason"):
|
|
assert leak not in joined
|
|
|
|
|
|
def test_the_failure_log_never_carries_the_hash_or_the_password(client, caplog):
|
|
with caplog.at_level(logging.WARNING, logger=_AUTH_LOGGER):
|
|
client.post(
|
|
"/api/auth/login",
|
|
json={"username": "admin", "password": "some-guessed-password"},
|
|
)
|
|
|
|
assert "some-guessed-password" not in caplog.text
|
|
assert TEST_ADMIN_PASSWORD not in caplog.text
|
|
assert "pbkdf2_sha256$" not in caplog.text
|
|
|
|
|
|
def test_a_malformed_hash_still_costs_a_full_password_check(client, monkeypatch):
|
|
"""verify_password returns from an unparseable digest without doing any
|
|
PBKDF2 work - measured at 0.16ms against 439ms for a real one. Left alone,
|
|
an account with a corrupt hash would answer ~2700x faster than every other
|
|
username and announce itself to anyone with a stopwatch, inverting the
|
|
property _DUMMY_HASH exists to provide. So the work must still be paid."""
|
|
checked = []
|
|
real_verify = auth_router.verify_password
|
|
|
|
def spy(password, encoded):
|
|
checked.append(encoded)
|
|
return real_verify(password, encoded)
|
|
|
|
monkeypatch.setattr(auth_router, "AUTH_ADMIN_PASSWORD_HASH", "not-a-hash")
|
|
monkeypatch.setattr(auth_router, "verify_password", spy)
|
|
|
|
assert client.post(
|
|
"/api/auth/login", json={"username": "admin", "password": TEST_ADMIN_PASSWORD}
|
|
).status_code == 401
|
|
|
|
assert len(checked) == 1, "exactly one verification per attempt"
|
|
assert hash_is_wellformed(checked[0]), "the broken hash must not short-circuit it"
|