Files
praxis/tests/test_auth.py
T
Praxis CI 00e39a3f85 feat(milestone): merge phase/01 operator-foundation → milestone/v0.4-operator-tier
Phase 1 complete — Operator Foundation:
- Postgres 16 in Docker-in-LXC (asyncpg pool, 5-table schema, PgStore, migrations)
- Operator auth (argon2id, signed stateless cookies, slowapi 5/min rate limit)
- VC issuer key migration SQLite→Postgres (archive-before-active, R-VC-MIG-01)
- Operator bootstrap CLI (create-operator.py, idempotent)
- Backup cron script + G-008 restore drill
- Graceful degradation (server starts without Postgres)
- 272 tests pass, 33 skip (Postgres-requiring), 0 fail

---ci---
project: praxis
phase: 1
milestone: v0.4
status: complete
requirements:
  covered: [REQ-MT-01, REQ-AUTH-01, REQ-NFR-AUTH-01, REQ-NFR-MT-01, REQ-MT-02]
  partial: []
---/ci---
2026-08-04 01:41:06 +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