ResearchRAG / tests /test_api_auth.py
riezqidr's picture
feat: implement PDF upload validation to prevent empty and invalid files
f1fa34c
Raw
History Blame Contribute Delete
6.54 kB
"""
Authentication tests: password policy, hashing migration, token lifecycle.
"""
from __future__ import annotations
import hashlib
import secrets
import pytest
from tests.conftest import VALID_PASSWORD, auth_header, register_and_login
# ─── Password policy (H2) ────────────────────────────────────────────────────
@pytest.mark.parametrize(
"password,reason",
[
("short1!A", "too short"),
("alllowercase123!", "no uppercase"),
("ALLUPPERCASE123!", "no lowercase"),
("NoDigitsHere!!!!", "no digit"),
("NoSymbolsHere123", "no special character"),
],
)
def test_register_rejects_weak_password(client, password, reason):
res = client.post(
"/api/auth/register",
json={"username": "weakuser", "display_name": "W", "password": password},
)
assert res.status_code == 422, f"accepted a password with {reason}"
def test_register_accepts_strong_password(client):
res = client.post(
"/api/auth/register",
json={"username": "stronguser", "display_name": "S", "password": VALID_PASSWORD},
)
assert res.status_code == 200
assert res.json()["success"] is True
def test_passwords_are_never_stored_in_plaintext(client, temp_auth_db):
register_and_login(client, "alice")
from app.auth import _conn
with _conn() as db:
stored = db.execute(
"SELECT password_hash FROM users WHERE username = 'alice'"
).fetchone()[0]
assert VALID_PASSWORD not in stored
assert stored.startswith("pbkdf2_sha256$"), "not using the PBKDF2 scheme"
assert int(stored.split("$")[1]) >= 600_000, "iteration count below OWASP guidance"
# ─── Legacy migration (H1) ───────────────────────────────────────────────────
def test_legacy_sha256_user_logs_in_and_is_rehashed(client, temp_auth_db):
"""
AUDIT H1: accounts created under salted single-round SHA-256 must keep
working and be silently upgraded β€” a migration that locks users out is not
a migration.
"""
from app.auth import _conn, login_user
from app.security import needs_rehash
username, password = "legacy", "oldpassword"
salt = secrets.token_hex(16)
legacy_hash = hashlib.sha256((salt + password).encode()).hexdigest()
with _conn() as db:
db.execute(
"INSERT INTO users (username, display_name, password_hash, salt) VALUES (?,?,?,?)",
(username, "Legacy", legacy_hash, salt),
)
db.commit()
def stored() -> str:
with _conn() as db:
return db.execute(
"SELECT password_hash FROM users WHERE username = ?", (username,)
).fetchone()[0]
assert needs_rehash(stored())
ok, _, display = login_user(username, password)
assert ok and display == "Legacy"
upgraded = stored()
assert upgraded.startswith("pbkdf2_sha256$")
assert not needs_rehash(upgraded)
# Still usable after the upgrade, and still rejects the wrong password.
assert login_user(username, password)[0] is True
assert login_user(username, "wrong")[0] is False
# ─── Token lifecycle ─────────────────────────────────────────────────────────
def test_login_returns_token_pair(client):
tokens = register_and_login(client, "alice")
assert tokens["token_type"] == "bearer"
assert tokens["access_token"].count(".") == 2, "access token is not a JWT"
assert tokens["refresh_token"]
assert tokens["expires_in"] > 0
def test_login_with_wrong_password_is_401(client):
register_and_login(client, "alice")
res = client.post("/api/auth/login", json={"username": "alice", "password": "Wr0ng!Passw0rd"})
assert res.status_code == 401
def test_access_token_grants_access(client):
tokens = register_and_login(client, "alice")
me = client.get("/api/auth/me", headers=auth_header(tokens))
assert me.status_code == 200
assert me.json()["user_id"] == "alice"
def test_expired_access_token_is_rejected(client, monkeypatch):
from api.settings import get_api_settings
tokens = register_and_login(client, "alice")
# Re-issue with a negative TTL to simulate expiry deterministically.
cfg = get_api_settings()
monkeypatch.setattr(cfg, "access_token_ttl_minutes", -1)
from api.security import issue_access_token
expired, _ = issue_access_token("alice")
res = client.get("/api/auth/me", headers={"Authorization": f"Bearer {expired}"})
assert res.status_code == 401
assert "expired" in res.json()["message"].lower()
# The original (valid) token still works β€” we only invalidated the new one.
assert client.get("/api/auth/me", headers=auth_header(tokens)).status_code == 200
def test_refresh_rotates_and_detects_reuse(client):
"""
Rotation + theft detection: a spent refresh token must not work twice, and
presenting one is treated as compromise, killing the whole family.
"""
tokens = register_and_login(client, "alice")
original = tokens["refresh_token"]
first = client.post("/api/auth/refresh", json={"refresh_token": original})
assert first.status_code == 200
rotated = first.json()["refresh_token"]
assert rotated != original
# Replay of the spent token β†’ rejected.
assert client.post("/api/auth/refresh", json={"refresh_token": original}).status_code == 401
# …and the family is revoked, so the legitimate successor dies too.
assert client.post("/api/auth/refresh", json={"refresh_token": rotated}).status_code == 401
def test_logout_revokes_refresh_token(client):
tokens = register_and_login(client, "alice")
assert client.post(
"/api/auth/logout", json={"refresh_token": tokens["refresh_token"]}
).status_code == 204
assert client.post(
"/api/auth/refresh", json={"refresh_token": tokens["refresh_token"]}
).status_code == 401
def test_refresh_token_cannot_be_used_as_access_token(client):
"""Token type confusion: a refresh token must not authenticate a data route."""
tokens = register_and_login(client, "alice")
res = client.get(
"/api/auth/me", headers={"Authorization": f"Bearer {tokens['refresh_token']}"}
)
assert res.status_code == 401