Files
Praxis CI ec397f2c65 docs(milestone): complete v0.5-live-assist — v0.1.13 tagged, milestone release, merged to main
v0.5 (Live Assist — on-the-job voice companion) milestone complete.
4 phases: P0 (pre-execution, v0.1.10) → P1 (assist core + guardrail,
v0.1.11) → P2 (integration + tech-debt + NFR, v0.1.12) → P3 (final
review + ship, v0.1.13 = milestone release).

16/16 REQs covered (3 ASSIST + 4 NFR + 9 IDEATE). 4 v0.6 backlog.
469 tests passed, 0 failed. 1 P0 fixed (guardrail processor safety).
8 P1+ flagged for v0.6. 8 v0.4 P1+ tech-debt addressed.
G-049 + G-067 grill MUSTs resolved. ESCALATION-01 (PIPEDA) OPEN for
human legal review before assist surface go-live.

---ci---
project: praxis
phase: 3
milestone: v0.5
status: complete
requirements:
  covered: [REQ-ASSIST-01, REQ-ASSIST-02, REQ-ASSIST-03, REQ-NFR-ASSIST-01, REQ-NFR-ASSIST-02, REQ-NFR-ASSIST-03, REQ-NFR-ASSIST-04, REQ-IDEATE-01, REQ-IDEATE-02, REQ-IDEATE-03, REQ-IDEATE-04, REQ-IDEATE-05, REQ-IDEATE-06, REQ-IDEATE-07, REQ-IDEATE-08, REQ-IDEATE-09]
  partial: []
---/ci---
2026-08-04 22:35:56 +00:00

587 lines
22 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
def test_cookie_secret_short_logs_warning_accepted(monkeypatch, caplog):
"""TASK-12-02 (P1+ #3): a secret <32 bytes logs a WARNING but is accepted.
A short non-empty secret (e.g., 'x') weakens the HMAC signature. The
secret is still accepted (backward compat — pilot); post-pilot this
should be a hard error. The WARNING is logged with remediation guidance.
"""
from loguru import logger as _logger
monkeypatch.setenv("PRAXIS_COOKIE_SECRET", "short-secret") # 11 bytes < 32
monkeypatch.setenv("PRAXIS_COOKIE_SECURE", "true")
# Capture loguru warnings.
msgs: list[str] = []
sink_id = _logger.add(lambda m: msgs.append(str(m)), level="WARNING")
try:
kw = get_session_middleware_kwargs()
finally:
_logger.remove(sink_id)
# The short secret is accepted (backward compat — no hard error in pilot).
assert kw["secret_key"] == "short-secret"
# A WARNING about the short secret was logged.
assert any("<32 bytes" in m for m in msgs), \
"short PRAXIS_COOKIE_SECRET should log a <32 bytes WARNING"
def test_cookie_secret_32_bytes_no_warning(monkeypatch, caplog):
"""TASK-12-02: a secret >=32 bytes logs no <32 bytes warning."""
from loguru import logger as _logger
monkeypatch.setenv("PRAXIS_COOKIE_SECRET", "x" * 32) # exactly 32 bytes
monkeypatch.setenv("PRAXIS_COOKIE_SECURE", "true")
msgs: list[str] = []
sink_id = _logger.add(lambda m: msgs.append(str(m)), level="WARNING")
try:
kw = get_session_middleware_kwargs()
finally:
_logger.remove(sink_id)
assert kw["secret_key"] == "x" * 32
# No <32 bytes warning (the secret is exactly 32 bytes).
assert not any("<32 bytes" in m for m in msgs), \
"32-byte secret should NOT log a <32 bytes warning"
def test_cookie_secret_long_no_warning(monkeypatch):
"""TASK-12-02: a secret >32 bytes logs no warning."""
from loguru import logger as _logger
monkeypatch.setenv("PRAXIS_COOKIE_SECRET", "x" * 64) # 64 bytes
monkeypatch.setenv("PRAXIS_COOKIE_SECURE", "true")
msgs: list[str] = []
sink_id = _logger.add(lambda m: msgs.append(str(m)), level="WARNING")
try:
kw = get_session_middleware_kwargs()
finally:
_logger.remove(sink_id)
assert kw["secret_key"] == "x" * 64
assert not any("<32 bytes" in m for m in msgs)
# ── 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
# ── TASK-12-04 (P1+ #1/#2/#5): argon2id offload + 429 mock test + audit log ──
def test_login_argon2id_offloaded_to_thread():
"""TASK-12-04 (P1+ #1): verify_password is offloaded to asyncio.to_thread.
The login handler should call verify_password via asyncio.to_thread (not
directly) so the ~100-300ms argon2id hashing does not block the event loop.
We verify by patching asyncio.to_thread to record the call.
"""
import asyncio as _asyncio
op = {
"id": "44444444-4444-4444-4444-444444444444",
"username": "erin",
"display_name": "Erin",
"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)
to_thread_calls: list = []
real_to_thread = _asyncio.to_thread
async def _spy_to_thread(func, *args, **kwargs):
to_thread_calls.append((func, args, kwargs))
return await real_to_thread(func, *args, **kwargs)
import server.auth.routes as _routes_mod
orig = _routes_mod.asyncio.to_thread
_routes_mod.asyncio.to_thread = _spy_to_thread
try:
app = _make_app_with_store(store)
with TestClient(app) as client:
r = client.post("/api/operator/login", json={"username": "erin", "password": "pw"})
assert r.status_code == 200
finally:
_routes_mod.asyncio.to_thread = orig
# verify_password should have been called via asyncio.to_thread.
assert to_thread_calls, "login should offload verify_password to asyncio.to_thread"
func = to_thread_calls[0][0]
assert func.__name__ == "verify_password", (
f"expected verify_password offloaded, got {func.__name__}"
)
def test_login_rehash_offloaded_to_thread():
"""TASK-12-04 (P1+ #1): hash_password (rehash) is also offloaded to thread."""
from argon2 import PasswordHasher
weak_hasher = PasswordHasher(time_cost=1, memory_cost=8, parallelism=1)
op = {
"id": "55555555-5555-5555-5555-555555555555",
"username": "frank",
"display_name": "Frank",
"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()
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)
import asyncio as _asyncio
import server.auth.routes as _routes_mod
to_thread_calls: list = []
real_to_thread = _routes_mod.asyncio.to_thread
async def _spy_to_thread(func, *args, **kwargs):
to_thread_calls.append((func, args, kwargs))
return await real_to_thread(func, *args, **kwargs)
_routes_mod.asyncio.to_thread = _spy_to_thread
try:
app = _make_app_with_store(store)
with TestClient(app) as client:
r = client.post("/api/operator/login", json={"username": "frank", "password": "pw"})
assert r.status_code == 200
finally:
_routes_mod.asyncio.to_thread = real_to_thread
# Both verify_password + hash_password should be offloaded.
func_names = [c[0].__name__ for c in to_thread_calls]
assert "verify_password" in func_names
assert "hash_password" in func_names, "rehash should offload hash_password to thread"
def test_login_rate_limit_429_after_5_attempts():
"""TASK-12-04 (P1+ #2): mock-based 429 test — 6th login attempt → 429.
The full 6th-attempt→429 path is in the PG-requiring integration test; this
adds a mock-based test for CI coverage without Postgres. slowapi's in-memory
limiter tracks per-IP; 5/minute → 6th attempt gets 429.
"""
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from slowapi import _rate_limit_exceeded_handler
op = {
"id": "66666666-6666-6666-6666-666666666666",
"username": "grace",
"display_name": "Grace",
"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)
app.state.limiter = limiter
app.add_middleware(SlowAPIMiddleware)
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
with TestClient(app) as client:
# 5 attempts should succeed (or 401 for wrong password — both count).
statuses: list[int] = []
for _ in range(5):
r = client.post(
"/api/operator/login", json={"username": "grace", "password": "pw"}
)
statuses.append(r.status_code)
# The 5 attempts should not be 429 (within the 5/minute limit).
assert all(s != 429 for s in statuses), f"first 5 should not be 429: {statuses}"
# 6th attempt → 429 (rate limit exceeded).
r6 = client.post(
"/api/operator/login", json={"username": "grace", "password": "pw"}
)
assert r6.status_code == 429, (
f"6th login attempt should be rate-limited (429), got {r6.status_code}"
)
def test_credential_revocation_logs_audit_event():
"""TASK-12-04 (P1+ #5): credential revocation logs operator + cred_id.
The revoke_credential endpoint should log an application-level audit event
(no audit_log table — the log is sufficient for pilot per D-056).
"""
import logging as _logging
from server.operator.credentials import router as creds_router
op = {
"id": "77777777-7777-7777-7777-777777777777",
"username": "heidi",
"display_name": "Heidi",
"role": "operator",
}
store = MagicMock()
store.get_credential = AsyncMock(return_value={"id": "cred-xyz", "status": "active"})
store.set_credential_status = AsyncMock()
app = FastAPI()
app.state.pg_store = store
app.add_middleware(SessionMiddleware, secret_key="test-secret-1234567890abcdef")
app.include_router(creds_router)
# Stub auth.
from server.auth.dependencies import current_operator
from server.auth.models import Operator
async def _stub_op():
return Operator(id=op["id"], username=op["username"],
display_name=op["display_name"], role=op["role"])
app.dependency_overrides[current_operator] = _stub_op
# Capture the audit log.
cred_log = _logging.getLogger("server.operator.credentials")
records: list[_logging.LogRecord] = []
handler = _logging.Handler()
handler.emit = records.append # type: ignore[method-assign]
cred_log.addHandler(handler)
cred_log.setLevel(_logging.INFO)
try:
with TestClient(app) as client:
r = client.post("/api/operator/credentials/cred-xyz/revoke")
assert r.status_code == 200
assert r.json()["status"] == "revoked"
finally:
cred_log.removeHandler(handler)
# The audit log should contain the operator id + cred_id.
audit_msgs = [r.getMessage() for r in records if r.levelno >= _logging.INFO]
assert any("credential revoked" in m for m in audit_msgs), \
f"revocation should log 'credential revoked': {audit_msgs}"
assert any("cred-xyz" in m for m in audit_msgs), \
f"audit log should contain cred_id: {audit_msgs}"
assert any(op["id"] in m for m in audit_msgs), \
f"audit log should contain operator id: {audit_msgs}"