Files
praxis/tests/test_auth.py
T
Praxis CI f2a12f9fed docs(milestone): complete v0.4-operator-tier — v0.1.9 tagged, milestone release, merged to main
v0.4 (Operator Tier — Cohort Dashboard + Auth + Postgres) milestone complete.

Phases:
  ✓ P0  pre-execution (planning)        → v0.1.6
  ✓ P1  operator foundation (Postgres+auth+VC migration) → v0.1.7
  ✓ P2  cohort dashboard + aggregation   → v0.1.8
  ✓ P3  final review + ship              → v0.1.9 (= v0.4 milestone release)

Requirements covered (8/8):
  REQ-MT-01 (Postgres store), REQ-MT-02 (aggregation pipeline),
  REQ-AUTH-01 (operator auth), REQ-DASH-01 (cohort dashboard),
  REQ-NFR-AUTH-01 (auth NFRs), REQ-NFR-MT-01 (Postgres-in-LXC),
  REQ-NFR-DASH-01 (k-anonymity ≥10), REQ-NFR-DASH-02 (freshness ≤24h)

Grill MUSTs honored (6/6): G-008, G-011, G-027, G-031, G-038, G-041

Tests: 317 pytest pass, 36 skip (Postgres-requiring), 0 fail; 17/17 vitest pass
Review: APPROVE_WITH_NOTES (6/6 personas, 0 P0, 8 P1+ carry-forward)
Audit: HEALTHY (reconstruction PASS, 8/8 REQ, 6/6 grill)

---ci---
project: praxis
phase: 3
milestone: v0.4
status: complete
phase_role: final
milestone_complete: true
milestone_merged_to_main: true
tag: v0.1.9
requirements:
  covered: [REQ-MT-01, REQ-MT-02, REQ-AUTH-01, REQ-DASH-01, REQ-NFR-AUTH-01, REQ-NFR-MT-01, REQ-NFR-DASH-01, REQ-NFR-DASH-02]
  partial: []
---/ci---
2026-08-04 11:58:44 +00:00

310 lines
12 KiB
Python

"""Auth unit tests (TASK-03-06) — mocked PgStore, no real Postgres.
Covers: password hash/verify/rehash, cookie config (secure flag, missing
secret), rate limiter threshold, current_operator dependency (401/503
cases, active/inactive), login/logout/me route handlers.
"""
from __future__ import annotations
import os
import types
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from starlette.middleware.sessions import SessionMiddleware
from server.auth.cookies import get_session_middleware_kwargs
from server.auth.dependencies import current_operator
from server.auth.models import Operator
from server.auth.passwords import hash_password, needs_rehash, verify_password
from server.auth.rate_limit import limiter, rate_limit_login, reset_login_rate_limit
from server.auth.routes import router
@pytest.fixture(autouse=True)
def _reset_limiter():
reset_login_rate_limit()
yield
reset_login_rate_limit()
# ── Passwords ───────────────────────────────────────────────────────────────
def test_password_hash_verify_roundtrip():
h = hash_password("correct horse battery staple")
assert h.startswith("$argon2id$")
assert verify_password(h, "correct horse battery staple") is True
def test_password_verify_wrong_returns_false():
h = hash_password("secret-1")
assert verify_password(h, "secret-2") is False
# no exception raised — uniform 401 path
assert verify_password(h, "") is False
def test_needs_rehash_false_for_current_defaults():
h = hash_password("x")
assert needs_rehash(h) is False
def test_needs_rehash_true_for_weak_hash():
# A hash produced with weaker params triggers rehash.
from argon2 import PasswordHasher
weak = PasswordHasher(time_cost=1, memory_cost=8, parallelism=1).hash("x")
assert needs_rehash(weak) is True
# ── Cookie config ───────────────────────────────────────────────────────────
def test_cookie_kwargs_defaults(monkeypatch):
monkeypatch.setenv("PRAXIS_COOKIE_SECRET", "x" * 48)
monkeypatch.setenv("PRAXIS_COOKIE_SECURE", "true")
kw = get_session_middleware_kwargs()
assert kw["session_cookie"] == "praxis_op"
assert kw["max_age"] == 28800
# Starlette SessionMiddleware: https_only (not secure), same_site (not samesite),
# httponly is always True (no kwarg). path is the cookie path.
assert kw["https_only"] is True
assert kw["same_site"] == "strict"
assert kw["path"] == "/"
def test_cookie_secure_false(monkeypatch):
monkeypatch.setenv("PRAXIS_COOKIE_SECRET", "x" * 48)
monkeypatch.setenv("PRAXIS_COOKIE_SECURE", "false")
kw = get_session_middleware_kwargs()
assert kw["https_only"] is False
def test_cookie_secret_unset_generates_random(monkeypatch):
monkeypatch.delenv("PRAXIS_COOKIE_SECRET", raising=False)
kw = get_session_middleware_kwargs()
assert kw["secret_key"]
assert len(kw["secret_key"]) >= 32
# ── current_operator dependency ─────────────────────────────────────────────
def _make_app_with_store(store) -> FastAPI:
app = FastAPI()
app.state.pg_store = store
app.add_middleware(SessionMiddleware, secret_key="test-secret-1234567890abcdef")
app.include_router(router)
return app
def _mock_store(operator_row=None):
store = MagicMock()
store.get_operator_by_id = AsyncMock(return_value=operator_row)
return store
def test_current_operator_no_cookie_401():
app = _make_app_with_store(_mock_store(operator_row=None))
with TestClient(app) as client:
r = client.get("/api/operator/me")
assert r.status_code == 401
def test_current_operator_no_postgres_503():
app = FastAPI()
app.state.pg_store = None
app.add_middleware(SessionMiddleware, secret_key="test-secret-1234567890abcdef")
app.include_router(router)
with TestClient(app) as client:
r = client.get("/api/operator/me")
assert r.status_code == 503
def test_current_operator_inactive_401():
op = {
"id": "11111111-1111-1111-1111-111111111111",
"username": "ghost",
"display_name": "Ghost",
"role": "operator",
"is_active": False,
}
store = _mock_store(operator_row=op)
app = _make_app_with_store(store)
with TestClient(app) as client:
# seed a session by hitting login would need a real store; instead
# set the session directly via a cookie. Use TestClient's cookie jar.
# Easiest: POST /login with a mocked store that returns the op.
store.get_operator_by_username = AsyncMock(return_value=op)
store.update_last_login = AsyncMock()
store.pool = MagicMock()
conn = MagicMock()
conn.execute = AsyncMock()
cm = MagicMock()
cm.__aenter__ = AsyncMock(return_value=conn)
cm.__aexit__ = AsyncMock(return_value=None)
store.pool.acquire = MagicMock(return_value=cm)
# hash the password so verify works
op = dict(op)
op["password_hash"] = hash_password("pw")
store.get_operator_by_username = AsyncMock(return_value=op)
r = client.post("/api/operator/login", json={"username": "ghost", "password": "pw"})
# inactive operator → 401 even with correct password
assert r.status_code == 401
def test_current_operator_valid_cookie_returns_operator():
op = {
"id": "22222222-2222-2222-2222-222222222222",
"username": "alice",
"display_name": "Alice",
"role": "operator",
"is_active": True,
"password_hash": hash_password("pw"),
}
store = _mock_store(operator_row=op)
store.get_operator_by_username = AsyncMock(return_value=op)
store.update_last_login = AsyncMock()
store.pool = MagicMock()
conn = MagicMock()
conn.execute = AsyncMock()
cm = MagicMock()
cm.__aenter__ = AsyncMock(return_value=conn)
cm.__aexit__ = AsyncMock(return_value=None)
store.pool.acquire = MagicMock(return_value=cm)
app = _make_app_with_store(store)
with TestClient(app) as client:
r = client.post("/api/operator/login", json={"username": "alice", "password": "pw"})
assert r.status_code == 200
assert r.json()["operator"]["username"] == "alice"
# cookie is now set; /me should work
r2 = client.get("/api/operator/me")
assert r2.status_code == 200
assert r2.json()["operator"]["username"] == "alice"
# ── Login route ─────────────────────────────────────────────────────────────
def test_login_wrong_password_401_no_cookie():
op = {
"id": "33333333-3333-3333-3333-333333333333",
"username": "bob",
"display_name": None,
"role": "operator",
"is_active": True,
"password_hash": hash_password("correct"),
}
store = _mock_store(operator_row=op)
store.get_operator_by_username = AsyncMock(return_value=op)
store.update_last_login = AsyncMock()
app = _make_app_with_store(store)
with TestClient(app) as client:
r = client.post("/api/operator/login", json={"username": "bob", "password": "wrong"})
assert r.status_code == 401
# no auth cookie set on failure
cookies = client.cookies.get("praxis_op")
assert not cookies
def test_login_unknown_user_401():
store = _mock_store(operator_row=None)
store.get_operator_by_username = AsyncMock(return_value=None)
app = _make_app_with_store(store)
with TestClient(app) as client:
r = client.post("/api/operator/login", json={"username": "nobody", "password": "x"})
assert r.status_code == 401
def test_login_no_postgres_503():
app = FastAPI()
app.state.pg_store = None
app.add_middleware(SessionMiddleware, secret_key="test-secret-1234567890abcdef")
app.include_router(router)
with TestClient(app) as client:
r = client.post("/api/operator/login", json={"username": "a", "password": "b"})
assert r.status_code == 503
# ── Logout ──────────────────────────────────────────────────────────────────
def test_logout_clears_session():
op = {
"id": "44444444-4444-4444-4444-444444444444",
"username": "carol",
"display_name": "Carol",
"role": "operator",
"is_active": True,
"password_hash": hash_password("pw"),
}
store = _mock_store(operator_row=op)
store.get_operator_by_username = AsyncMock(return_value=op)
store.update_last_login = AsyncMock()
store.pool = MagicMock()
conn = MagicMock()
conn.execute = AsyncMock()
cm = MagicMock()
cm.__aenter__ = AsyncMock(return_value=conn)
cm.__aexit__ = AsyncMock(return_value=None)
store.pool.acquire = MagicMock(return_value=cm)
app = _make_app_with_store(store)
with TestClient(app) as client:
client.post("/api/operator/login", json={"username": "carol", "password": "pw"})
r = client.post("/api/operator/logout")
assert r.status_code == 200
assert r.json()["ok"] is True
# /me now 401
r2 = client.get("/api/operator/me")
assert r2.status_code == 401
# ── Rehash on login ─────────────────────────────────────────────────────────
def test_login_rehash_when_needed():
from argon2 import PasswordHasher
weak_hasher = PasswordHasher(time_cost=1, memory_cost=8, parallelism=1)
op = {
"id": "55555555-5555-5555-5555-555555555555",
"username": "dave",
"display_name": "Dave",
"role": "operator",
"is_active": True,
"password_hash": weak_hasher.hash("pw"),
}
store = _mock_store(operator_row=op)
store.get_operator_by_username = AsyncMock(return_value=op)
store.update_last_login = AsyncMock()
store.pool = MagicMock()
executed = []
conn = MagicMock()
async def _exec(*a, **kw):
executed.append(a)
conn.execute = _exec
cm = MagicMock()
cm.__aenter__ = AsyncMock(return_value=conn)
cm.__aexit__ = AsyncMock(return_value=None)
store.pool.acquire = MagicMock(return_value=cm)
app = _make_app_with_store(store)
with TestClient(app) as client:
r = client.post("/api/operator/login", json={"username": "dave", "password": "pw"})
assert r.status_code == 200
assert executed, "rehash UPDATE should have run"
# the second arg to execute is the new hash; verify it's argon2id
assert executed[0][1].startswith("$argon2id$")
# ── Rate limiter ────────────────────────────────────────────────────────────
def test_rate_limit_login_decorator():
# The decorator factory returns a decorator; applying it should not raise.
deco = rate_limit_login()
assert callable(deco)
def test_limiter_is_in_memory():
assert getattr(limiter, "_storage_uri", "memory://") == "memory://" or limiter._storage is not None