ec397f2c65
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---
587 lines
22 KiB
Python
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}" |