00e39a3f85
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---
310 lines
12 KiB
Python
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 |